From 0e0e45c73d0b9609d37b0b981653e6f82feea8d0 Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Wed, 26 Nov 2025 03:55:05 +0000 Subject: [PATCH 01/11] Initial plan From 27a6c432301a71d6ca2fa34ed545e47516549681 Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Wed, 26 Nov 2025 04:10:31 +0000 Subject: [PATCH 02/11] feat: Add OpenTelemetry instrumentation for Google ADK from PR #57 Co-authored-by: ralf0131 <4397305+ralf0131@users.noreply.github.com> --- .../README.md | 238 +++++++ .../examples/main.py | 421 +++++++++++ .../examples/tools.py | 178 +++++ .../pyproject.toml | 82 +++ .../src/opentelemetry/__init__.py | 3 + .../opentelemetry/instrumentation/__init__.py | 3 + .../instrumentation/google_adk/__init__.py | 145 ++++ .../google_adk/internal/__init__.py | 2 + .../google_adk/internal/_extractors.py | 471 +++++++++++++ .../google_adk/internal/_metrics.py | 226 ++++++ .../google_adk/internal/_plugin.py | 659 ++++++++++++++++++ .../google_adk/internal/_utils.py | 241 +++++++ .../instrumentation/google_adk/version.py | 4 + .../tests/__init__.py | 2 + .../tests/test_metrics.py | 604 ++++++++++++++++ .../tests/test_plugin_integration.py | 587 ++++++++++++++++ .../tests/test_utils.py | 265 +++++++ 17 files changed, 4131 insertions(+) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/README.md create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/main.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/tools.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_metrics.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_plugin_integration.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/README.md b/instrumentation-genai/opentelemetry-instrumentation-google-adk/README.md new file mode 100644 index 00000000..17e503f7 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/README.md @@ -0,0 +1,238 @@ +# OpenTelemetry Google ADK Instrumentation + +Google ADK (Agent Development Kit) Python Agent provides observability for Google ADK applications. +This document provides examples of usage and results in the Google ADK instrumentation. +For details on usage and installation of LoongSuite and Jaeger, please refer to [LoongSuite Documentation](https://github.com/alibaba/loongsuite-python-agent/blob/main/README.md). + +## Installing Google ADK Instrumentation + +```shell +# Open Telemetry +pip install opentelemetry-distro opentelemetry-exporter-otlp +opentelemetry-bootstrap -a install + +# google-adk +pip install google-adk>=0.1.0 +pip install litellm + +# GoogleAdkInstrumentor +git clone https://github.com/alibaba/loongsuite-python-agent.git +cd loongsuite-python-agent +pip install ./instrumentation-genai/opentelemetry-instrumentation-google-adk +``` + +## Collect Data + +Here's a simple demonstration of Google ADK instrumentation. The demo uses: + +- A [Google ADK application](examples/simple_adk_app.py) that demonstrates agent interactions + +### Running the Demo + +#### Option 1: Using OpenTelemetry + +```bash +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + +opentelemetry-instrument \ +--traces_exporter console \ +--service_name demo-google-adk \ +python examples/main.py +``` + +#### Option 2: Using Loongsuite + +```bash +export DASHSCOPE_API_KEY=xxxx +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + +loongsuite-instrument \ +--traces_exporter console \ +--service_name demo-google-adk \ +python examples/main.py +``` + +### Results + +The instrumentation will generate traces showing the Google ADK operations: + +```bash +{ + "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.description": "xxx", + "gen_ai.tool.name": "get_current_time", + "gen_ai.tool.type": "FunctionTool", + "gcp.vertex.agent.llm_request": "{}", + "gcp.vertex.agent.llm_response": "{}", + "gcp.vertex.agent.tool_call_args": "{}", + "gen_ai.tool.call.id": "xxx", + "gcp.vertex.agent.event_id": "xxxx", + "gcp.vertex.agent.tool_response": "xxx" + }, + "events": [], + "links": [], + "resource": { + "attributes": { + "telemetry.sdk.language": "python", + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.version": "1.37.0", + "service.name": "demo-google-adk", + "telemetry.auto.version": "0.59b0" + }, + "schema_url": "" + } +} +``` + +After [setting up jaeger](https://www.jaegertracing.io/docs/1.6/getting-started/) and exporting data to it by following these commands: + +```bash +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + +loongsuite-instrument \ +--exporter_otlp_protocol grpc \ +--traces_exporter otlp \ +--exporter_otlp_insecure true \ +--exporter_otlp_endpoint YOUR-END-POINT \ +python examples/main.py +``` + +You can see traces on the jaeger UI: + + + +## 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` | + +### 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. + +2. **Instrumentation Not Working**: Check that the instrumentation is enabled and the Google ADK application is using the `Runner` class. + +3. **Missing Traces**: Verify that the OpenTelemetry exporters are properly configured. + +## 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-genai/opentelemetry-instrumentation-google-adk/examples/main.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/main.py new file mode 100644 index 00000000..47cc4284 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/main.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +""" +工具使用示例 (HTTP 服务版本) +展示如何在 ADK Agent 中使用各种工具函数并部署为 HTTP 服务 +""" + +import os +import sys +import asyncio +import logging +from datetime import datetime +from typing import Optional, Dict, Any + +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.tools import FunctionTool + from google.adk.runners import Runner + from google.adk.sessions.in_memory_session_service import InMemorySessionService + 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 ( + get_current_time, + calculate_math, + roll_dice, + check_prime_numbers, + get_weather_info, + 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 + 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(f"🔑 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(f"🔑 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-genai/opentelemetry-instrumentation-google-adk/examples/tools.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/tools.py new file mode 100644 index 00000000..3f710c65 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/tools.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +工具函数定义 +包含各种类型的工具函数供 Agent 使用 +""" + +import math +import random +import json +from datetime import datetime +from typing import List, Dict, Any + +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-genai/opentelemetry-instrumentation-google-adk/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml new file mode 100644 index 00000000..e82e2b50 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml @@ -0,0 +1,82 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-google-adk" +version = "0.1.0" +description = "OpenTelemetry instrumentation for Google Agent Development Kit (ADK)" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.8" +authors = [ + { name = "OpenTelemetry Contributors" }, +] +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.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "opentelemetry-api ~= 1.27", + "opentelemetry-sdk ~= 1.27", + "opentelemetry-semantic-conventions ~= 0.48b0", + "wrapt >= 1.0.0, < 2.0.0", + "google-adk >= 0.1.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/your-org/loongsuite-python-agent" + +[project.entry-points.opentelemetry_instrumentor] +google-adk = "opentelemetry.instrumentation.google_adk:GoogleAdkInstrumentor" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] + +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "--cov=opentelemetry/instrumentation/google_adk --cov-report=term-missing --cov-report=html" + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "@abstractmethod", +] + + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/__init__.py new file mode 100644 index 00000000..a3223933 --- /dev/null +++ b/instrumentation-genai/opentelemetry-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-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py new file mode 100644 index 00000000..00c9fac6 --- /dev/null +++ b/instrumentation-genai/opentelemetry-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-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py new file mode 100644 index 00000000..398c1037 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py @@ -0,0 +1,145 @@ +""" +OpenTelemetry Instrumentation for Google ADK. + +This package provides OpenTelemetry instrumentation for Google Agent Development Kit (ADK) +applications, following the OpenTelemetry GenAI semantic conventions. + +Usage: + from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor + + GoogleAdkInstrumentor().instrument() +""" + +import logging +from typing import Collection + +from opentelemetry import trace as trace_api, metrics as metrics_api +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.semconv.schemas import Schemas +from wrapt import wrap_function_wrapper + +from .internal._plugin import GoogleAdkObservabilityPlugin +from .version import __version__ + +_logger = logging.getLogger(__name__) + + +class GoogleAdkInstrumentor(BaseInstrumentor): + """ + OpenTelemetry instrumentor for Google ADK. + + This instrumentor automatically injects observability into Google ADK applications + following OpenTelemetry GenAI semantic conventions. + """ + + def __init__(self): + """Initialize the instrumentor.""" + super().__init__() + self._plugin = None + self._original_plugins = None + + 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. + + Args: + **kwargs: Optional keyword arguments: + - tracer_provider: Custom tracer provider + - meter_provider: Custom meter provider + """ + # Lazy import to avoid import errors when google-adk is not installed + try: + import google.adk.runners + except ImportError: + _logger.warning("google-adk not found, instrumentation will not be applied") + return + + tracer_provider = kwargs.get("tracer_provider") + meter_provider = kwargs.get("meter_provider") + + # Get tracer and meter + tracer = trace_api.get_tracer( + __name__, + __version__, + tracer_provider, + schema_url=Schemas.V1_28_0.value, + ) + + meter = metrics_api.get_meter( + __name__, + __version__, + meter_provider, + schema_url=Schemas.V1_28_0.value, + ) + + # Create and store the plugin instance + self._plugin = GoogleAdkObservabilityPlugin(tracer, meter) + + # Wrap the Runner initialization to auto-inject our plugin + try: + wrap_function_wrapper( + "google.adk.runners", + "Runner.__init__", + self._runner_init_wrapper + ) + _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 + """ + try: + # Unwrap the Runner initialization + from google.adk.runners import Runner + unwrap(Runner, "__init__") + + self._plugin = None + _logger.info("Google ADK instrumentation disabled") + except Exception as e: + _logger.exception(f"Failed to uninstrument Google ADK: {e}") + + def _runner_init_wrapper(self, wrapped, instance, args, kwargs): + """ + Wrapper for Runner.__init__ to auto-inject the observability plugin. + + Args: + wrapped: Original wrapped function + instance: Runner instance + args: Positional arguments + kwargs: Keyword arguments + + Returns: + Result of the original function + """ + # 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 self._plugin and self._plugin not in plugins: + plugins.append(self._plugin) + kwargs['plugins'] = plugins + _logger.debug("Injected OpenTelemetry observability plugin into Runner") + + # Call the original __init__ + return wrapped(*args, **kwargs) + + +__all__ = ["GoogleAdkInstrumentor"] + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py new file mode 100644 index 00000000..c67545b6 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py @@ -0,0 +1,2 @@ +"""Internal implementation modules for Google ADK instrumentation.""" + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py new file mode 100644 index 00000000..28156c4d --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py @@ -0,0 +1,471 @@ +""" +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). +""" + +import json +import logging +from typing import Any, Dict, 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.models.llm_request import LlmRequest +from google.adk.models.llm_response import LlmResponse +from google.adk.tools.base_tool import BaseTool +from google.adk.tools.tool_context import ToolContext + +from ._utils import ( + safe_json_dumps, safe_json_dumps_large, extract_content_safely, + safe_json_dumps_for_input_output, extract_content_safely_for_input_output, + should_capture_content, process_content +) + +_logger = logging.getLogger(__name__) + + +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_common_attributes( + self, + operation_name: str, + conversation_id: Optional[str] = None, + user_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + Extract common GenAI attributes required for all spans. + + Args: + operation_name: Operation name (chat, invoke_agent, execute_tool, etc.) + conversation_id: Conversation/session ID (optional) + user_id: User ID (optional) + + Returns: + Dictionary of common attributes + """ + attrs = { + "gen_ai.operation.name": operation_name, + "gen_ai.provider.name": "google_adk" # ✅ 使用 provider.name 而非 system + } + + # ✅ conversation.id 而非 session.id + if conversation_id and isinstance(conversation_id, str): + attrs["gen_ai.conversation.id"] = conversation_id + + # ✅ 使用标准 enduser.id 而非 gen_ai.user.id + if user_id and isinstance(user_id, str): + attrs["enduser.id"] = user_id + + return attrs + + def extract_runner_attributes( + self, + invocation_context: InvocationContext + ) -> Dict[str, Any]: + """ + Extract attributes for Runner spans (top-level invoke_agent span). + + Args: + invocation_context: ADK invocation context + + Returns: + Dictionary of runner attributes + """ + try: + _logger.debug("Extracting runner attributes") + + # Extract conversation_id and user_id from invocation_context + conversation_id = None + user_id = None + + try: + conversation_id = invocation_context.session.id + except AttributeError: + _logger.debug("Failed to extract conversation_id from invocation_context") + + try: + user_id = getattr(invocation_context, 'user_id', None) + if not user_id and hasattr(invocation_context, 'session'): + user_id = getattr(invocation_context.session, 'user_id', None) + except AttributeError: + _logger.debug("Failed to extract user_id from invocation_context") + + if conversation_id is None: + _logger.debug("conversation_id not found on invocation_context") + if user_id is None: + _logger.debug("user_id not found on invocation_context") + + # ✅ 使用 invoke_agent 操作名称 + attrs = self.extract_common_attributes( + operation_name="invoke_agent", + conversation_id=conversation_id, + user_id=user_id + ) + + # Add ADK-specific attributes (非标准,作为自定义扩展) + if hasattr(invocation_context, 'app_name'): + attrs["google_adk.runner.app_name"] = invocation_context.app_name + + if hasattr(invocation_context, 'invocation_id'): + attrs["google_adk.runner.invocation_id"] = invocation_context.invocation_id + + # Agent spans use input.value/output.value + attrs["input.mime_type"] = "application/json" + attrs["output.mime_type"] = "application/json" + + return attrs + + except Exception as e: + _logger.exception(f"Error extracting runner attributes: {e}") + return self.extract_common_attributes("invoke_agent") + + def extract_agent_attributes( + self, + agent: BaseAgent, + callback_context: CallbackContext + ) -> Dict[str, Any]: + """ + Extract attributes for Agent spans. + + Args: + agent: ADK agent instance + callback_context: ADK callback context + + Returns: + Dictionary of agent attributes + """ + try: + _logger.debug("Extracting agent attributes") + + # Extract conversation_id and user_id from callback_context + conversation_id = None + user_id = None + + try: + conversation_id = callback_context._invocation_context.session.id + except AttributeError: + _logger.debug("Failed to extract conversation_id from callback_context") + + try: + user_id = getattr(callback_context, 'user_id', None) + if not user_id: + user_id = getattr(callback_context._invocation_context, 'user_id', None) + except AttributeError: + _logger.debug("Failed to extract user_id from callback_context") + + if conversation_id is None: + _logger.debug("conversation_id not found on callback_context") + if user_id is None: + _logger.debug("user_id not found on callback_context") + + # ✅ 使用 invoke_agent 操作名称(无论是 agent 还是 chain) + attrs = self.extract_common_attributes( + operation_name="invoke_agent", + conversation_id=conversation_id, + user_id=user_id + ) + + # ✅ 使用 gen_ai.agent.* 属性(带前缀) + if hasattr(agent, 'name') and agent.name: + attrs["gen_ai.agent.name"] = agent.name + + # ✅ 尝试获取 agent.id(如果可用) + if hasattr(agent, 'id') and agent.id: + attrs["gen_ai.agent.id"] = agent.id + + if hasattr(agent, 'description') and agent.description: + attrs["gen_ai.agent.description"] = agent.description + + # Add input/output placeholder + attrs["input.mime_type"] = "application/json" + attrs["output.mime_type"] = "application/json" + + return attrs + + except Exception as e: + _logger.exception(f"Error extracting agent attributes: {e}") + return self.extract_common_attributes("invoke_agent") + + def extract_llm_request_attributes( + self, + llm_request: LlmRequest, + callback_context: CallbackContext + ) -> Dict[str, Any]: + """ + Extract attributes for LLM request spans. + + Args: + llm_request: ADK LLM request + callback_context: ADK callback context + + Returns: + Dictionary of LLM request attributes + """ + try: + # Extract conversation_id and user_id + conversation_id = None + user_id = None + + try: + conversation_id = callback_context._invocation_context.session.id + except AttributeError: + _logger.debug("Failed to extract conversation_id from callback_context") + + try: + user_id = getattr(callback_context, 'user_id', None) + if not user_id: + user_id = getattr(callback_context._invocation_context, 'user_id', None) + except AttributeError: + _logger.debug("Failed to extract user_id from callback_context") + + # ✅ 使用 chat 操作名称 + attrs = self.extract_common_attributes( + operation_name="chat", + conversation_id=conversation_id, + user_id=user_id + ) + + # Add LLM request attributes according to GenAI conventions + if hasattr(llm_request, 'model') and llm_request.model: + # ✅ 只使用 gen_ai.request.model(移除冗余的 model_name) + attrs["gen_ai.request.model"] = llm_request.model + # ✅ 使用 _extract_provider_name 而非 _extract_system_from_model + attrs["gen_ai.provider.name"] = self._extract_provider_name(llm_request.model) + + # Extract request parameters + if hasattr(llm_request, 'config') and llm_request.config: + config = llm_request.config + + if hasattr(config, 'max_tokens') and config.max_tokens: + attrs["gen_ai.request.max_tokens"] = config.max_tokens + + if hasattr(config, 'temperature') and config.temperature is not None: + if isinstance(config.temperature, (int, float)): + attrs["gen_ai.request.temperature"] = config.temperature + + if hasattr(config, 'top_p') and config.top_p is not None: + if isinstance(config.top_p, (int, float)): + attrs["gen_ai.request.top_p"] = config.top_p + + if hasattr(config, 'top_k') and config.top_k is not None: + if isinstance(config.top_k, (int, float)): + attrs["gen_ai.request.top_k"] = config.top_k + + # Extract input messages (with content capture control) + if should_capture_content() and hasattr(llm_request, 'contents') and llm_request.contents: + try: + input_messages = [] + for content in llm_request.contents: + if hasattr(content, 'role') and hasattr(content, 'parts'): + # Convert to GenAI message format + message = { + "role": content.role, + "parts": [] + } + for part in content.parts: + if hasattr(part, 'text'): + message["parts"].append({ + "type": "text", + "content": process_content(part.text) + }) + input_messages.append(message) + + if input_messages: + attrs["gen_ai.input.messages"] = safe_json_dumps_large(input_messages) + + except Exception as e: + _logger.debug(f"Failed to extract input messages: {e}") + + attrs["input.mime_type"] = "application/json" + # ❌ 移除 gen_ai.request.is_stream (非标准属性) + + return attrs + + except Exception as e: + _logger.exception(f"Error extracting LLM request attributes: {e}") + return self.extract_common_attributes("chat") + + def extract_llm_response_attributes( + self, + llm_response: LlmResponse + ) -> Dict[str, Any]: + """ + Extract attributes for LLM response. + + Args: + llm_response: ADK LLM response + + Returns: + Dictionary of LLM response attributes + """ + try: + attrs = {} + + # Add response model + if hasattr(llm_response, 'model') and llm_response.model: + attrs["gen_ai.response.model"] = llm_response.model + + # ✅ finish_reasons (复数数组) + if hasattr(llm_response, 'finish_reason'): + finish_reason = llm_response.finish_reason or 'stop' + attrs["gen_ai.response.finish_reasons"] = [finish_reason] # 必须是数组 + + # Add token usage + if hasattr(llm_response, 'usage_metadata') and llm_response.usage_metadata: + usage = llm_response.usage_metadata + + if hasattr(usage, 'prompt_token_count') and usage.prompt_token_count: + attrs["gen_ai.usage.input_tokens"] = usage.prompt_token_count + + if hasattr(usage, 'candidates_token_count') and usage.candidates_token_count: + attrs["gen_ai.usage.output_tokens"] = usage.candidates_token_count + # ❌ 移除 gen_ai.usage.total_tokens (非标准,可自行计算) + + # Extract output messages (with content capture control) + if should_capture_content() and hasattr(llm_response, 'content') and llm_response.content: + try: + output_messages = [] + # Check if response has text content + if hasattr(llm_response, 'text') and llm_response.text is not None: + extracted_text = extract_content_safely_for_input_output(llm_response.text) + message = { + "role": "assistant", + "parts": [{ + "type": "text", + "content": process_content(extracted_text) + }], + "finish_reason": getattr(llm_response, 'finish_reason', None) or 'stop' + } + output_messages.append(message) + elif hasattr(llm_response, 'content') and llm_response.content is not None: + extracted_text = extract_content_safely_for_input_output(llm_response.content) + message = { + "role": "assistant", + "parts": [{ + "type": "text", + "content": process_content(extracted_text) + }], + "finish_reason": getattr(llm_response, 'finish_reason', None) or 'stop' + } + output_messages.append(message) + + if output_messages: + attrs["gen_ai.output.messages"] = safe_json_dumps_large(output_messages) + + except Exception as e: + _logger.debug(f"Failed to extract output messages: {e}") + + attrs["output.mime_type"] = "application/json" + + return attrs + + except Exception as e: + _logger.exception(f"Error extracting LLM response attributes: {e}") + return {} + + def extract_tool_attributes( + self, + tool: BaseTool, + tool_args: dict[str, Any], + tool_context: ToolContext + ) -> Dict[str, Any]: + """ + Extract attributes for Tool spans. + + Args: + tool: ADK tool instance + tool_args: Tool arguments + tool_context: Tool context + + Returns: + Dictionary of tool attributes + """ + try: + # 尝试从tool_context提取conversation_id + conversation_id = None + user_id = None + + if hasattr(tool_context, 'session_id'): + conversation_id = tool_context.session_id + elif hasattr(tool_context, 'context') and hasattr(tool_context.context, 'session_id'): + conversation_id = tool_context.context.session_id + + # ✅ 使用 execute_tool 操作名称 + attrs = self.extract_common_attributes( + operation_name="execute_tool", + conversation_id=conversation_id, + user_id=user_id + ) + + # ✅ Tool 属性使用 gen_ai.tool.* 前缀 + if hasattr(tool, 'name') and tool.name: + attrs["gen_ai.tool.name"] = tool.name + + if hasattr(tool, 'description') and tool.description: + attrs["gen_ai.tool.description"] = tool.description + + # ✅ 默认 tool type 为 function + attrs["gen_ai.tool.type"] = "function" + + # ✅ 尝试获取 tool.call.id(如果可用) + if hasattr(tool_context, 'call_id') and tool_context.call_id: + attrs["gen_ai.tool.call.id"] = tool_context.call_id + + # ✅ tool.call.arguments 而非 tool.parameters (Opt-In) + if should_capture_content() and tool_args: + attrs["gen_ai.tool.call.arguments"] = safe_json_dumps(tool_args) + attrs["input.value"] = safe_json_dumps_for_input_output(tool_args) + attrs["input.mime_type"] = "application/json" + + return attrs + + except Exception as e: + _logger.exception(f"Error extracting tool attributes: {e}") + return self.extract_common_attributes("execute_tool") + + 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-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py new file mode 100644 index 00000000..ed0e9202 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py @@ -0,0 +1,226 @@ +""" +OpenTelemetry GenAI Metrics Collector for Google ADK. + +This module implements standard OpenTelemetry GenAI metrics collection +following the latest GenAI semantic conventions. +""" + +import logging +from typing import Optional + +from opentelemetry.metrics import Meter +from opentelemetry.semconv._incubating.metrics import gen_ai_metrics + +_logger = logging.getLogger(__name__) + + +class Instruments: + """ + Standard OpenTelemetry GenAI instrumentation instruments. + + This class follows the same pattern as openai-v2/instruments.py + and implements only the 2 standard GenAI client metrics. + """ + + def __init__(self, meter: Meter): + """ + Initialize standard GenAI instruments. + + Args: + meter: OpenTelemetry meter instance + """ + # ✅ Standard GenAI client metric 1: Operation duration + self.operation_duration_histogram = ( + gen_ai_metrics.create_gen_ai_client_operation_duration(meter) + ) + + # ✅ Standard GenAI client metric 2: Token usage + self.token_usage_histogram = ( + gen_ai_metrics.create_gen_ai_client_token_usage(meter) + ) + + +class AdkMetricsCollector: + """ + Metrics collector for Google ADK following OpenTelemetry GenAI conventions. + + This collector implements ONLY the 2 standard GenAI client metrics: + - gen_ai.client.operation.duration (Histogram, unit: seconds) + - gen_ai.client.token.usage (Histogram, unit: tokens) + + All ARMS-specific metrics have been removed. + """ + + def __init__(self, meter: Meter): + """ + Initialize the metrics collector. + + Args: + meter: OpenTelemetry meter instance + """ + self._instruments = Instruments(meter) + _logger.debug("AdkMetricsCollector initialized with standard OTel GenAI metrics") + + def record_llm_call( + self, + operation_name: str, + model_name: str, + duration: float, + error_type: Optional[str] = None, + prompt_tokens: int = 0, + completion_tokens: int = 0, + conversation_id: Optional[str] = None, + user_id: Optional[str] = None + ) -> None: + """ + Record LLM call metrics following standard OTel GenAI conventions. + + Args: + operation_name: Operation name (e.g., "chat") + model_name: Model name + duration: Duration in seconds + error_type: Error type if error occurred + prompt_tokens: Number of prompt tokens + completion_tokens: Number of completion tokens + conversation_id: Conversation ID (not used in metrics due to high cardinality) + user_id: User ID (not used in metrics due to high cardinality) + """ + try: + # ✅ Build standard attributes for operation.duration + attributes = { + "gen_ai.operation.name": operation_name, + "gen_ai.provider.name": "google_adk", # ✅ Required attribute + "gen_ai.request.model": model_name, # ✅ Recommended attribute + } + + # ✅ Add error.type only if error occurred (Conditionally Required) + if error_type: + attributes["error.type"] = error_type + + # ✅ Record operation duration (Histogram, unit: seconds) + self._instruments.operation_duration_histogram.record( + duration, + attributes=attributes + ) + + # ✅ Record token usage (Histogram, unit: tokens) + # Note: session_id and user_id are NOT included in metrics (high cardinality) + if prompt_tokens > 0: + self._instruments.token_usage_histogram.record( + prompt_tokens, + attributes={ + **attributes, + "gen_ai.token.type": "input", # ✅ Required for token.usage + } + ) + + if completion_tokens > 0: + self._instruments.token_usage_histogram.record( + completion_tokens, + attributes={ + **attributes, + "gen_ai.token.type": "output", # ✅ Required for token.usage + } + ) + + _logger.debug( + f"Recorded LLM metrics: operation={operation_name}, model={model_name}, " + f"duration={duration:.3f}s, prompt_tokens={prompt_tokens}, " + f"completion_tokens={completion_tokens}, error={error_type}" + ) + + except Exception as e: + _logger.exception(f"Error recording LLM metrics: {e}") + + def record_agent_call( + self, + operation_name: str, + agent_name: str, + duration: float, + error_type: Optional[str] = None, + conversation_id: Optional[str] = None, + user_id: Optional[str] = None + ) -> None: + """ + Record Agent call metrics following standard OTel GenAI conventions. + + Args: + operation_name: Operation name (e.g., "invoke_agent") + agent_name: Agent name + duration: Duration in seconds + error_type: Error type if error occurred + conversation_id: Conversation ID (not used in metrics due to high cardinality) + user_id: User ID (not used in metrics due to high cardinality) + """ + try: + # ✅ Build standard attributes + attributes = { + "gen_ai.operation.name": operation_name, + "gen_ai.provider.name": "google_adk", # ✅ Required + "gen_ai.request.model": agent_name, # ✅ Agent name as model + } + + # ✅ Add error.type only if error occurred + if error_type: + attributes["error.type"] = error_type + + # ✅ Record operation duration (Histogram, unit: seconds) + self._instruments.operation_duration_histogram.record( + duration, + attributes=attributes + ) + + _logger.debug( + f"Recorded Agent metrics: operation={operation_name}, agent={agent_name}, " + f"duration={duration:.3f}s, error={error_type}" + ) + + except Exception as e: + _logger.exception(f"Error recording Agent metrics: {e}") + + def record_tool_call( + self, + operation_name: str, + tool_name: str, + duration: float, + error_type: Optional[str] = None, + conversation_id: Optional[str] = None, + user_id: Optional[str] = None + ) -> None: + """ + Record Tool call metrics following standard OTel GenAI conventions. + + Args: + operation_name: Operation name (e.g., "execute_tool") + tool_name: Tool name + duration: Duration in seconds + error_type: Error type if error occurred + conversation_id: Conversation ID (not used in metrics due to high cardinality) + user_id: User ID (not used in metrics due to high cardinality) + """ + try: + # ✅ Build standard attributes + attributes = { + "gen_ai.operation.name": operation_name, + "gen_ai.provider.name": "google_adk", # ✅ Required + "gen_ai.request.model": tool_name, # ✅ Tool name as model + } + + # ✅ Add error.type only if error occurred + if error_type: + attributes["error.type"] = error_type + + # ✅ Record operation duration (Histogram, unit: seconds) + self._instruments.operation_duration_histogram.record( + duration, + attributes=attributes + ) + + _logger.debug( + f"Recorded Tool metrics: operation={operation_name}, tool={tool_name}, " + f"duration={duration:.3f}s, error={error_type}" + ) + + except Exception as e: + _logger.exception(f"Error recording Tool metrics: {e}") + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py new file mode 100644 index 00000000..ff422b24 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py @@ -0,0 +1,659 @@ +""" +OpenTelemetry ADK Observability Plugin. + +This module implements the core observability plugin using Google ADK's +plugin mechanism with OpenTelemetry GenAI semantic conventions. +""" + +import logging +from typing import Any, Dict, 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 import trace as trace_api +from opentelemetry.metrics import Meter +from opentelemetry.trace import SpanKind + +from ._metrics import AdkMetricsCollector +from ._extractors import AdkAttributeExtractors +from ._utils import ( + safe_json_dumps, safe_json_dumps_for_input_output, extract_content_safely_for_input_output, + should_capture_content, process_content +) + +_logger = logging.getLogger(__name__) + + +class GoogleAdkObservabilityPlugin(BasePlugin): + """ + OpenTelemetry ADK Observability Plugin. + + Implements comprehensive observability for Google ADK applications + following OpenTelemetry GenAI semantic conventions. + """ + + def __init__(self, tracer: trace_api.Tracer, meter: Meter): + """ + Initialize the observability plugin. + + Args: + tracer: OpenTelemetry tracer instance + meter: OpenTelemetry meter instance + """ + super().__init__(name="opentelemetry_adk_observability") + self._tracer = tracer + self._metrics = AdkMetricsCollector(meter) + self._extractors = AdkAttributeExtractors() + + # Track active spans for proper nesting + self._active_spans: Dict[str, trace_api.Span] = {} + + # 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. + Span name: "invoke_agent {app_name}" + """ + try: + # ✅ Span name follows GenAI conventions + span_name = f"invoke_agent {invocation_context.app_name}" + attributes = self._extractors.extract_runner_attributes(invocation_context) + + # ✅ Use CLIENT span kind (recommended for GenAI) + span = self._tracer.start_span( + name=span_name, + kind=SpanKind.CLIENT, + attributes=attributes + ) + + # Store span for later use + self._active_spans[f"runner_{invocation_context.invocation_id}"] = span + + # Check if we already have a stored user message + runner_key = f"runner_{invocation_context.invocation_id}" + if runner_key in self._runner_inputs and should_capture_content(): + user_message = self._runner_inputs[runner_key] + input_messages = self._convert_user_message_to_genai_format(user_message) + + if input_messages: + # For Agent spans, use input.value + span.set_attribute("input.value", safe_json_dumps_for_input_output(input_messages)) + _logger.debug(f"Set input.value on Agent span: {invocation_context.invocation_id}") + + _logger.debug(f"Started Runner span: {span_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 + + # Set input messages on active Runner span if it exists and content capture is enabled + span = self._active_spans.get(runner_key) + if span and should_capture_content(): + input_messages = self._convert_user_message_to_genai_format(user_message) + + if input_messages: + # For Agent spans, use input.value + span.set_attribute("input.value", safe_json_dumps_for_input_output(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: + if not should_capture_content(): + return None + + # Extract text content from event if available + event_content = "" + if hasattr(event, 'content') and event.content: + event_content = extract_content_safely_for_input_output(event.content) + elif hasattr(event, 'data') and event.data: + event_content = extract_content_safely_for_input_output(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 + + # Set output on active Runner span + span = self._active_spans.get(runner_key) + if span: + output_messages = [{ + "role": "assistant", + "parts": [{ + "type": "text", + "content": process_content(self._runner_outputs[runner_key]) + }], + "finish_reason": "stop" + }] + + # For Agent spans, use output.value + span.set_attribute("output.value", safe_json_dumps_for_input_output(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: + span_key = f"runner_{invocation_context.invocation_id}" + span = self._active_spans.pop(span_key, None) + + if span: + # Record metrics + duration = self._calculate_span_duration(span) + + # Extract conversation_id and user_id + conversation_id = invocation_context.session.id if invocation_context.session else None + user_id = getattr(invocation_context, 'user_id', None) + + self._metrics.record_agent_call( + operation_name="invoke_agent", + agent_name=invocation_context.app_name, + duration=duration, + error_type=None, + conversation_id=conversation_id, + user_id=user_id + ) + + span.end() + _logger.debug(f"Finished Runner span for {invocation_context.app_name}") + + # Clean up stored data + runner_key = f"runner_{invocation_context.invocation_id}" + 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. + + Span name: "invoke_agent {agent.name}" + """ + try: + # ✅ Span name follows GenAI conventions + span_name = f"invoke_agent {agent.name}" + attributes = self._extractors.extract_agent_attributes(agent, callback_context) + + # ✅ Use CLIENT span kind + span = self._tracer.start_span( + name=span_name, + kind=SpanKind.CLIENT, + attributes=attributes + ) + + # Store span + agent_key = f"agent_{id(agent)}_{callback_context._invocation_context.session.id}" + self._active_spans[agent_key] = span + + _logger.debug(f"Started Agent span: {span_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 and record metrics. + """ + try: + agent_key = f"agent_{id(agent)}_{callback_context._invocation_context.session.id}" + span = self._active_spans.pop(agent_key, None) + + if span: + # Record metrics + duration = self._calculate_span_duration(span) + + # Extract conversation_id and user_id + conversation_id = None + user_id = None + if callback_context and callback_context._invocation_context: + if callback_context._invocation_context.session: + conversation_id = callback_context._invocation_context.session.id + user_id = getattr(callback_context._invocation_context, 'user_id', None) + + self._metrics.record_agent_call( + operation_name="invoke_agent", + agent_name=agent.name, + duration=duration, + error_type=None, + conversation_id=conversation_id, + user_id=user_id + ) + + span.end() + _logger.debug(f"Finished Agent span 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. + + Span name: "chat {model}" + """ + try: + # ✅ Span name follows GenAI conventions: "{operation_name} {request.model}" + span_name = f"chat {llm_request.model}" + attributes = self._extractors.extract_llm_request_attributes( + llm_request, callback_context + ) + + # ✅ Use CLIENT span kind for LLM calls + span = self._tracer.start_span( + name=span_name, + kind=SpanKind.CLIENT, + attributes=attributes + ) + + # Store span + session_id = callback_context._invocation_context.session.id + request_key = f"llm_{id(llm_request)}_{session_id}" + self._active_spans[request_key] = span + + # 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 span: {span_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 and record metrics. + """ + try: + # Find the matching span + llm_span = None + request_key = None + session_id = callback_context._invocation_context.session.id + for key, span in list(self._active_spans.items()): + if key.startswith("llm_") and session_id in key: + llm_span = self._active_spans.pop(key) + request_key = key + break + + if llm_span: + # Add response attributes + response_attrs = self._extractors.extract_llm_response_attributes(llm_response) + for key, value in response_attrs.items(): + llm_span.set_attribute(key, value) + + # Record metrics + duration = self._calculate_span_duration(llm_span) + + # Resolve model name with robust fallbacks + model_name = self._resolve_model_name(llm_response, request_key, llm_span) + + # Extract conversation_id and user_id + conversation_id = None + user_id = None + if callback_context and callback_context._invocation_context: + if callback_context._invocation_context.session: + conversation_id = callback_context._invocation_context.session.id + user_id = getattr(callback_context._invocation_context, 'user_id', None) + + # Extract token usage + prompt_tokens = 0 + completion_tokens = 0 + if llm_response and llm_response.usage_metadata: + prompt_tokens = getattr(llm_response.usage_metadata, 'prompt_token_count', 0) + completion_tokens = getattr(llm_response.usage_metadata, 'candidates_token_count', 0) + + self._metrics.record_llm_call( + operation_name="chat", + model_name=model_name, + duration=duration, + error_type=None, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + conversation_id=conversation_id, + user_id=user_id + ) + + llm_span.end() + _logger.debug(f"Finished LLM span 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 span with error status + session_id = callback_context._invocation_context.session.id + for key, span in list(self._active_spans.items()): + if key.startswith("llm_") and session_id in key: + span = self._active_spans.pop(key) + + # Set error attributes + error_type = type(error).__name__ + span.set_attribute("error.type", error_type) + + # Record error metrics + duration = self._calculate_span_duration(span) + model_name = llm_request.model if llm_request else "unknown" + + # Extract conversation_id and user_id + conversation_id = None + user_id = None + if callback_context and callback_context._invocation_context: + if callback_context._invocation_context.session: + conversation_id = callback_context._invocation_context.session.id + user_id = getattr(callback_context._invocation_context, 'user_id', None) + + self._metrics.record_llm_call( + operation_name="chat", + model_name=model_name, + duration=duration, + error_type=error_type, + prompt_tokens=0, + completion_tokens=0, + conversation_id=conversation_id, + user_id=user_id + ) + + # ✅ Use standard OTel span status for errors + span.set_status(trace_api.Status( + trace_api.StatusCode.ERROR, + description=str(error) + )) + span.end() + 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. + + Span name: "execute_tool {tool.name}" + """ + try: + # ✅ Span name follows GenAI conventions + span_name = f"execute_tool {tool.name}" + attributes = self._extractors.extract_tool_attributes( + tool, tool_args, tool_context + ) + + # ✅ Use INTERNAL span kind for tool execution (as per spec) + span = self._tracer.start_span( + name=span_name, + kind=SpanKind.INTERNAL, + attributes=attributes + ) + + # Store span + tool_key = f"tool_{id(tool)}_{id(tool_args)}" + self._active_spans[tool_key] = span + + _logger.debug(f"Started Tool span: {span_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 and record metrics. + """ + try: + tool_key = f"tool_{id(tool)}_{id(tool_args)}" + span = self._active_spans.pop(tool_key, None) + + if span: + # ✅ Add tool result as gen_ai.tool.call.result (Opt-In) + if should_capture_content() and result: + result_json = safe_json_dumps_for_input_output(result) + span.set_attribute("gen_ai.tool.call.result", result_json) + span.set_attribute("output.value", result_json) + span.set_attribute("output.mime_type", "application/json") + + # Record metrics + duration = self._calculate_span_duration(span) + + # Extract conversation_id and user_id from tool_context + conversation_id = getattr(tool_context, 'session_id', None) if tool_context else None + user_id = getattr(tool_context, 'user_id', None) if tool_context else None + + self._metrics.record_tool_call( + operation_name="execute_tool", + tool_name=tool.name, + duration=duration, + error_type=None, + conversation_id=conversation_id, + user_id=user_id + ) + + span.end() + _logger.debug(f"Finished Tool span 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)}" + span = self._active_spans.pop(tool_key, None) + + if span: + # Set error attributes + error_type = type(error).__name__ + span.set_attribute("error.type", error_type) + + # Record error metrics + duration = self._calculate_span_duration(span) + + # Extract conversation_id and user_id + conversation_id = getattr(tool_context, 'session_id', None) if tool_context else None + user_id = getattr(tool_context, 'user_id', None) if tool_context else None + + self._metrics.record_tool_call( + operation_name="execute_tool", + tool_name=tool.name, + duration=duration, + error_type=error_type, + conversation_id=conversation_id, + user_id=user_id + ) + + # ✅ Use standard OTel span status for errors + span.set_status(trace_api.Status( + trace_api.StatusCode.ERROR, + description=str(error) + )) + span.end() + + _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 ===== + + def _calculate_span_duration(self, span: trace_api.Span) -> float: + """ + Calculate span duration in seconds. + + Args: + span: OpenTelemetry span + + Returns: + Duration in seconds + """ + import time + + if hasattr(span, 'start_time') and span.start_time: + current_time_ns = time.time_ns() + return (current_time_ns - span.start_time) / 1_000_000_000 # ns to s + return 0.0 + + def _resolve_model_name( + self, + llm_response: LlmResponse, + request_key: str, + span: trace_api.Span + ) -> str: + """ + Resolve model name with robust fallbacks. + + Args: + llm_response: LLM response object + request_key: Request key for stored models + span: Current span + + 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) Try span attributes if accessible + if not model_name and hasattr(span, 'attributes') and getattr(span, 'attributes'): + model_name = span.attributes.get("gen_ai.request.model") + + # 4) Parse from span name like "chat " + if not model_name and hasattr(span, 'name') and isinstance(span.name, str): + try: + name = span.name + if name.startswith("chat ") and len(name) > 5: + model_name = name[5:] # Remove "chat " prefix + except Exception: + pass + + # 5) Final fallback + if not model_name: + model_name = "unknown" + + return model_name + + def _convert_user_message_to_genai_format(self, user_message: types.Content) -> list: + """ + Convert ADK user message to GenAI message format. + + Args: + user_message: ADK Content object + + Returns: + List of GenAI formatted messages + """ + input_messages = [] + if user_message and hasattr(user_message, 'role') and hasattr(user_message, 'parts'): + message = { + "role": user_message.role, + "parts": [] + } + for part in user_message.parts: + if hasattr(part, 'text'): + message["parts"].append({ + "type": "text", + "content": process_content(part.text) + }) + input_messages.append(message) + return input_messages + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py new file mode 100644 index 00000000..5a810de0 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py @@ -0,0 +1,241 @@ +""" +Utility functions for Google ADK instrumentation. + +This module provides common utility functions following OpenTelemetry standards. +""" + +import json +import os +from typing import Any, Optional + + +def should_capture_content() -> bool: + """ + Check if content capture is enabled via environment variable. + + Returns: + True if OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT is set to "true" + """ + return os.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "false").lower() == "true" + + +def get_max_content_length() -> Optional[int]: + """ + Get the configured maximum content length from environment variable. + + Returns: + Maximum length in characters, or None if not set + """ + limit_str = os.getenv('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') + if limit_str: + try: + return int(limit_str) + except ValueError: + pass + return None + + +def process_content(content: str) -> str: + """ + Process content with length limit and truncation. + + This replaces the ARMS SDK process_content() function with standard OTel behavior. + + Args: + content: Content string to process + + Returns: + Processed content with truncation marker if needed + """ + if not content: + return "" + + if not should_capture_content(): + return "" + + max_length = get_max_content_length() + if max_length is None or len(content) <= max_length: + return content + + # Add truncation marker + truncation_marker = " [TRUNCATED]" + effective_limit = max_length - len(truncation_marker) + if effective_limit <= 0: + return truncation_marker[:max_length] + + return content[:effective_limit] + truncation_marker + + +def safe_json_dumps(obj: Any, max_length: int = 1024, respect_env_limit: bool = False) -> str: + """ + Safely serialize an object to JSON with error handling and length limits. + + Args: + obj: Object to serialize + max_length: Maximum length of the resulting string (used as fallback) + respect_env_limit: If True, use environment variable limit instead of max_length + + Returns: + JSON string representation of the object + """ + try: + json_str = json.dumps(obj, ensure_ascii=False, default=str) + + if respect_env_limit: + json_str = process_content(json_str) + elif len(json_str) > max_length: + json_str = json_str[:max_length] + "...[truncated]" + + return json_str + except Exception: + fallback_str = str(obj) + if respect_env_limit: + return process_content(fallback_str) + else: + return fallback_str[:max_length] + + +def safe_json_dumps_large(obj: Any, max_length: int = 1048576, respect_env_limit: bool = True) -> str: + """ + Safely serialize large objects to JSON with extended length limits. + + This is specifically designed for content that may be large, such as + LLM input/output messages. + + Args: + obj: Object to serialize + max_length: Maximum length (default 1MB, used as fallback) + respect_env_limit: If True (default), use environment variable limit + + Returns: + JSON string representation of the object + """ + return safe_json_dumps(obj, max_length, respect_env_limit) + + +def extract_content_safely(content: Any, max_length: int = 1024, respect_env_limit: bool = True) -> str: + """ + Safely extract text content from various ADK content types. + + Args: + content: Content object (could be types.Content, string, etc.) + max_length: Maximum length of extracted content (used as fallback) + respect_env_limit: If True (default), use environment variable limit + + Returns: + String representation of the content + """ + if not content: + return "" + + try: + # Handle Google genai types.Content objects + 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) + content_str = "".join(text_parts) + elif hasattr(content, 'text'): + content_str = content.text + else: + content_str = str(content) + + # Apply length limit with proper truncation handling + if respect_env_limit: + return process_content(content_str) + elif len(content_str) > max_length: + content_str = content_str[:max_length] + "...[truncated]" + + return content_str + + except Exception: + fallback_str = str(content) if content else "" + if respect_env_limit: + return process_content(fallback_str) + else: + return fallback_str[:max_length] + + +def safe_json_dumps_for_input_output(obj: Any) -> str: + """ + Safely serialize objects for input/output attributes with environment variable length limit. + + This function is specifically designed for input.value and output.value attributes + and always respects the OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH environment variable. + + Args: + obj: Object to serialize + + Returns: + JSON string representation with proper truncation marker if needed + """ + return safe_json_dumps(obj, max_length=1048576, respect_env_limit=True) + + +def extract_content_safely_for_input_output(content: Any) -> str: + """ + Safely extract content for input/output attributes with environment variable length limit. + + This function is specifically designed for input/output content extraction + and always respects the OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH environment variable. + + Args: + content: Content object to extract text from + + Returns: + String representation with proper truncation marker if needed + """ + return extract_content_safely(content, max_length=1048576, respect_env_limit=True) + + +def extract_model_name(model_obj: Any) -> str: + """ + Extract model name from various model object types. + + Args: + model_obj: Model object or model name string + + Returns: + Model name string + """ + if isinstance(model_obj, str): + return model_obj + elif hasattr(model_obj, 'model') and model_obj.model: + return model_obj.model + elif hasattr(model_obj, 'name') and model_obj.name: + return model_obj.name + else: + return "unknown" + + +def is_slow_call(duration: float, threshold: float = 0.5) -> bool: + """ + Determine if a call should be considered slow. + + Args: + duration: Duration in seconds + threshold: Slow call threshold in seconds (default 500ms) + + Returns: + True if call is considered slow + """ + return duration > threshold + + +def get_error_attributes(error: Exception) -> dict: + """ + Extract error attributes from an exception. + + Args: + error: Exception object + + Returns: + Dictionary of error attributes + """ + return { + "error.type": type(error).__name__, + # Note: error.message is non-standard, OTel recommends using span status + # But we include it for debugging purposes + } + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py new file mode 100644 index 00000000..54d219ec --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py @@ -0,0 +1,4 @@ +"""Version information for OpenTelemetry Google ADK Instrumentation.""" + +__version__ = "0.1.0" + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/__init__.py new file mode 100644 index 00000000..13c52a1b --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/__init__.py @@ -0,0 +1,2 @@ +"""Tests for OpenTelemetry Google ADK Instrumentation.""" + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_metrics.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_metrics.py new file mode 100644 index 00000000..af018a89 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_metrics.py @@ -0,0 +1,604 @@ +""" +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 pytest +import asyncio +from unittest.mock import Mock +from typing import Dict, List, Any, Optional + +from opentelemetry import trace as trace_api +from opentelemetry.sdk import trace as trace_sdk +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk import metrics as metrics_sdk +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor + + +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 + } + + # Non-standard metrics that should NOT be present + NON_STANDARD_METRICS = { + # ARMS-specific metrics + "calls_count", + "calls_duration_seconds", + "call_error_count", + "llm_usage_tokens", + "llm_first_token_seconds", + # Custom GenAI metrics (non-standard) + "genai_calls_count", + "genai_calls_duration_seconds", + "genai_calls_error_count", + "genai_calls_slow_count", + "genai_llm_first_token_seconds", + "genai_llm_usage_tokens", + "genai_avg_first_token_seconds" + } + + def validate_metrics_data(self, metric_reader: InMemoryMetricReader) -> Dict[str, Any]: + """Validate metrics data against OTel GenAI conventions.""" + validation_result = { + "metrics_found": set(), + "non_standard_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) + + # Check for non-standard metrics + if metric.name in self.NON_STANDARD_METRICS: + validation_result["non_standard_found"].add(metric.name) + + # Validate individual metric + validation_result["metric_validations"][metric.name] = \ + self._validate_single_metric(metric) + + # Check for non-standard metrics + if validation_result["non_standard_found"]: + validation_result["errors"].append( + f"Found non-standard metrics: {validation_result['non_standard_found']}" + ) + + 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") + + # Check for non-standard attributes + non_standard_attrs = { + "callType", "callKind", "rpcType", "spanKind", # ARMS attributes + "modelName", "usageType", # Should be gen_ai.request.model, gen_ai.token.type + "session_id", "user_id" # High cardinality, should not be in metrics + } + + for attr in non_standard_attrs: + if attr in attributes: + result["errors"].append(f"Found non-standard attribute: {attr}") + + # 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: + 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 + - No non-standard attributes (callType, spanKind, modelName, etc.) + """ + # 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 for non-standard metrics + assert len(validation_result["non_standard_found"]) == 0, \ + f"Found non-standard metrics: {validation_result['non_standard_found']}" + + # 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" + + # Validate NO non-standard attributes + assert "callType" not in duration_attrs, "Should NOT have callType" + assert "spanKind" not in duration_attrs, "Should NOT have spanKind" + assert "modelName" not in duration_attrs, "Should NOT have modelName" + assert "session_id" not in duration_attrs, "Should NOT have session_id (high cardinality)" + assert "user_id" not in duration_attrs, "Should NOT have user_id (high cardinality)" + + # 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" + + # Validate NO usageType attribute (should be gen_ai.token.type) + input_attrs = dict(input_point.attributes) + assert "usageType" not in input_attrs, "Should NOT have usageType (use gen_ai.token.type)" + + @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_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" + + @pytest.mark.asyncio + async def test_agent_metrics_use_standard_attributes(self): + """ + Test that Agent metrics use standard OTel GenAI attributes. + + Validates: + - gen_ai.operation.name = "invoke_agent" + - Agent name mapped to gen_ai.request.model + - No ARMS-specific attributes + """ + # 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 = "math_tutor" + mock_agent.description = "Mathematical tutor agent" + mock_agent.sub_agents = [] + + mock_callback_context = create_mock_callback_context() + + # Execute Agent callbacks + await plugin.before_agent_callback( + agent=mock_agent, + callback_context=mock_callback_context + ) + + await asyncio.sleep(0.01) + + await plugin.after_agent_callback( + agent=mock_agent, + callback_context=mock_callback_context + ) + + # Get metrics data + duration_points = self.get_metric_data_points("gen_ai.client.operation.duration") + assert len(duration_points) >= 1, "Should have agent duration metric" + + # Validate attributes + agent_attrs = dict(duration_points[0].attributes) + assert agent_attrs.get("gen_ai.operation.name") == "invoke_agent", \ + "Should have gen_ai.operation.name = 'invoke_agent'" + assert "gen_ai.provider.name" in agent_attrs, "Should have provider name" + + # Agent name should be in gen_ai.request.model + assert agent_attrs.get("gen_ai.request.model") == "math_tutor" or \ + "gen_ai.agent.name" in agent_attrs, \ + "Agent name should be in metrics" + + # Validate NO ARMS attributes + assert "spanKind" not in agent_attrs, "Should NOT have spanKind" + assert "session_id" not in agent_attrs, "Should NOT have high-cardinality session_id" + assert "user_id" not in agent_attrs, "Should NOT have high-cardinality user_id" + + @pytest.mark.asyncio + async def test_tool_metrics_use_standard_attributes(self): + """ + Test that Tool metrics use standard OTel GenAI attributes. + + Validates: + - gen_ai.operation.name = "execute_tool" + - Tool name mapped to gen_ai.request.model + - Standard metric structure + """ + # 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 callbacks + await plugin.before_tool_callback( + tool=mock_tool, + tool_args=mock_tool_args, + tool_context=mock_tool_context + ) + + await asyncio.sleep(0.01) + + await plugin.after_tool_callback( + tool=mock_tool, + tool_args=mock_tool_args, + tool_context=mock_tool_context, + result=mock_result + ) + + # Get metrics data + duration_points = self.get_metric_data_points("gen_ai.client.operation.duration") + assert len(duration_points) >= 1, "Should have tool duration metric" + + # Validate attributes + tool_attrs = dict(duration_points[0].attributes) + assert tool_attrs.get("gen_ai.operation.name") == "execute_tool", \ + "Should have gen_ai.operation.name = 'execute_tool'" + assert "gen_ai.provider.name" in tool_attrs + + # Tool name should be in metrics + assert tool_attrs.get("gen_ai.request.model") == "calculator" or \ + "gen_ai.tool.name" in tool_attrs, \ + "Tool name should be in metrics" + + @pytest.mark.asyncio + async def test_only_two_standard_metrics_recorded(self): + """ + Test that only 2 standard OTel GenAI metrics are recorded. + + Validates: + - Only gen_ai.client.operation.duration + - Only gen_ai.client.token.usage + - NO ARMS metrics (calls_count, calls_duration_seconds, etc.) + - NO custom GenAI metrics (genai_calls_count, genai_llm_first_token_seconds, etc.) + """ + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider + ) + + plugin = self.instrumentor._plugin + + # Execute various operations + 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 + ) + + # Agent call + mock_agent = Mock() + mock_agent.name = "agent1" + mock_agent.sub_agents = [] + + await plugin.before_agent_callback(agent=mock_agent, callback_context=mock_context) + await plugin.after_agent_callback(agent=mock_agent, callback_context=mock_context) + + # 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}" + + # Should have NO non-standard metrics + assert len(validation_result["non_standard_found"]) == 0, \ + f"Should have NO non-standard metrics, found: {validation_result['non_standard_found']}" + + # Explicitly check ARMS metrics are NOT present + arms_metrics = { + "calls_count", "calls_duration_seconds", "call_error_count", + "llm_usage_tokens", "llm_first_token_seconds" + } + found_arms_metrics = validation_result["metrics_found"] & arms_metrics + assert len(found_arms_metrics) == 0, \ + f"Should NOT have ARMS metrics, found: {found_arms_metrics}" + + # Explicitly check custom GenAI metrics are NOT present + custom_genai_metrics = { + "genai_calls_count", "genai_calls_duration_seconds", + "genai_calls_error_count", "genai_calls_slow_count", + "genai_llm_first_token_seconds", "genai_llm_usage_tokens" + } + found_custom_metrics = validation_result["metrics_found"] & custom_genai_metrics + assert len(found_custom_metrics) == 0, \ + f"Should NOT have custom GenAI metrics, found: {found_custom_metrics}" + + +# Run tests +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_plugin_integration.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_plugin_integration.py new file mode 100644 index 00000000..c127bd56 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_plugin_integration.py @@ -0,0 +1,587 @@ +""" +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. +""" + +import pytest +import asyncio +from unittest.mock import Mock +from typing import Dict, List, Any + +from opentelemetry import trace as trace_api +from opentelemetry.sdk import trace as trace_sdk +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk import metrics as metrics_sdk +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor + + +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"} + } + } + + # Non-standard attributes that should NOT be present + NON_STANDARD_ATTRIBUTES = { + "gen_ai.span.kind", # Use gen_ai.operation.name instead + "gen_ai.system", # Use gen_ai.provider.name instead + "gen_ai.session.id", # Use gen_ai.conversation.id instead + "gen_ai.user.id", # Use enduser.id instead + "gen_ai.framework", # Non-standard + "gen_ai.model_name", # Redundant + "gen_ai.request.is_stream", # Non-standard + "gen_ai.usage.total_tokens", # Non-standard + "gen_ai.input.message_count", # Non-standard + "gen_ai.output.message_count", # Non-standard + } + + 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": [], + "non_standard_found": [] + } + + 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}'" + ) + + # Check for non-standard attributes + for attr_key in attributes.keys(): + if attr_key in self.NON_STANDARD_ATTRIBUTES: + validation_result["non_standard_found"].append(attr_key) + + # 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: + 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']}" + + # Check for non-standard attributes + assert len(validation_result["non_standard_found"]) == 0, \ + f"Found non-standard attributes: {validation_result['non_standard_found']}" + + # 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" + + # Validate NO non-standard attributes + assert "gen_ai.span.kind" not in attributes, \ + "Should NOT have gen_ai.span.kind (non-standard)" + assert "gen_ai.system" not in attributes, \ + "Should NOT have gen_ai.system (use gen_ai.provider.name)" + assert "gen_ai.framework" not in attributes, \ + "Should NOT have gen_ai.framework (non-standard)" + + @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_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/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py new file mode 100644 index 00000000..832dad1f --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py @@ -0,0 +1,265 @@ +""" +Unit tests for utility functions. +""" + +import os +import pytest +from opentelemetry.instrumentation.google_adk.internal._utils import ( + should_capture_content, + get_max_content_length, + process_content, + safe_json_dumps, + safe_json_dumps_large, + extract_content_safely, + extract_model_name, + is_slow_call, + get_error_attributes +) + + +class TestContentCapture: + """Tests for content capture utilities.""" + + def test_should_capture_content_default_false(self): + """Test that content capture is disabled by default.""" + # Clear environment variable + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT', None) + # Default is False unless explicitly enabled + assert should_capture_content() is False + + def test_should_capture_content_enabled(self): + """Test that content capture can be explicitly enabled.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + assert should_capture_content() is True + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + + def test_should_capture_content_disabled(self): + """Test that content capture can be disabled.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'false' + assert should_capture_content() is False + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + + def test_get_max_length_default(self): + """Test default max length.""" + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH', None) + # Return value is Optional[int], so None is valid + max_len = get_max_content_length() + assert max_len is None or max_len > 0 + + def test_get_max_length_custom(self): + """Test custom max length.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH'] = '1000' + assert get_max_content_length() == 1000 + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') + + def test_get_max_length_invalid(self): + """Test invalid max length returns None.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH'] = 'invalid' + assert get_max_content_length() is None + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') + + def test_process_content_short_string(self): + """Test processing short content.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + content = "Hello, world!" + result = process_content(content) + assert result == content + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + + def test_process_content_long_string(self): + """Test processing long content - may be truncated if max length set.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + os.environ['OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH'] = '1000' + content = "A" * 10000 + result = process_content(content) + # Result should be truncated + assert isinstance(result, str) + assert len(result) <= 1000 + assert "[TRUNCATED]" in result + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') + + def test_process_content_when_disabled(self): + """Test processing content when capture is disabled.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'false' + content = "B" * 200 + result = process_content(content) + # Should return empty string when disabled + assert result == "" + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + + def test_process_content_with_custom_max_length(self): + """Test processing content with custom max length.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + os.environ['OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH'] = '100' + content = "B" * 200 + result = process_content(content) + assert len(result) <= 100 + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') + + +class TestJsonUtils: + """Tests for JSON utility functions.""" + + def test_safe_json_dumps_basic(self): + """Test basic JSON serialization.""" + data = {"key": "value", "number": 42} + result = safe_json_dumps(data) + assert '"key": "value"' in result + assert '"number": 42' in result + + def test_safe_json_dumps_nested(self): + """Test nested JSON serialization.""" + data = { + "outer": { + "inner": ["a", "b", "c"] + } + } + result = safe_json_dumps(data) + assert "outer" in result + assert "inner" in result + + def test_safe_json_dumps_error_fallback(self): + """Test fallback for non-serializable objects.""" + class NonSerializable: + pass + + data = {"obj": NonSerializable()} + result = safe_json_dumps(data) + # Should return some string representation without crashing + assert isinstance(result, str) + + def test_extract_content_safely_string(self): + """Test extracting string content.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + result = extract_content_safely("test string") + assert result == "test string" + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + + def test_extract_content_safely_dict(self): + """Test extracting dict content.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + data = {"message": "test"} + result = extract_content_safely(data) + assert "message" in result + assert "test" in result + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + + def test_extract_content_safely_list(self): + """Test extracting list content.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + data = ["item1", "item2"] + result = extract_content_safely(data) + assert "item1" in result + assert "item2" in result + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + + def test_extract_content_safely_when_disabled(self): + """Test extracting content when capture is disabled.""" + os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'false' + result = extract_content_safely("test string") + # Should return empty string when capture is disabled + assert result == "" + os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') + + def test_extract_content_safely_none(self): + """Test extracting None content.""" + result = extract_content_safely(None) + assert result == "" + + +class TestModelUtils: + """Tests for model-related utility functions.""" + + def test_extract_model_name_simple(self): + """Test extracting simple model name.""" + result = extract_model_name("gpt-4") + assert result == "gpt-4" + + def test_extract_model_name_with_provider(self): + """Test extracting model name from full path.""" + # extract_model_name returns the string as-is if it's a string + result = extract_model_name("providers/google/models/gemini-pro") + assert result == "providers/google/models/gemini-pro" + + def test_extract_model_name_empty(self): + """Test extracting empty model name.""" + # Empty string is still a string, so it returns as-is + result = extract_model_name("") + assert result == "" + + def test_extract_model_name_none(self): + """Test extracting None model name.""" + result = extract_model_name(None) + assert result == "unknown" + + def test_extract_model_name_from_object(self): + """Test extracting model name from object with model attribute.""" + from unittest.mock import Mock + mock_obj = Mock() + mock_obj.model = "gemini-pro" + result = extract_model_name(mock_obj) + assert result == "gemini-pro" + + +class TestSpanUtils: + """Tests for span-related utility functions.""" + + def test_is_slow_call_threshold_exceeded(self): + """Test slow call detection when threshold exceeded.""" + # 2 seconds with 1 second threshold + assert is_slow_call(2.0, threshold=1.0) is True + + def test_is_slow_call_threshold_not_exceeded(self): + """Test slow call detection when threshold not exceeded.""" + # 0.5 seconds with 1 second threshold + assert is_slow_call(0.5, threshold=1.0) is False + + def test_is_slow_call_default_threshold(self): + """Test slow call detection with default threshold.""" + # Assuming default threshold is 0.5 seconds + # 1 second should be slow + assert is_slow_call(1.0) is True + # 0.1 seconds should not be slow + assert is_slow_call(0.1) is False + + +class TestErrorUtils: + """Tests for error handling utilities.""" + + def test_get_error_attributes_basic(self): + """Test getting error attributes for basic exception.""" + error = ValueError("test error") + attrs = get_error_attributes(error) + + assert attrs["error.type"] == "ValueError" + assert "ValueError" in attrs["error.type"] + + def test_get_error_attributes_timeout(self): + """Test getting error attributes for timeout.""" + error = TimeoutError("Operation timed out") + attrs = get_error_attributes(error) + + assert attrs["error.type"] == "TimeoutError" + + def test_get_error_attributes_custom_exception(self): + """Test getting error attributes for custom exception.""" + class CustomError(Exception): + pass + + error = CustomError("custom message") + attrs = get_error_attributes(error) + + assert attrs["error.type"] == "CustomError" + + def test_get_error_attributes_none(self): + """Test getting error attributes when None is passed.""" + # Even None has a type, so error.type will be 'NoneType' + attrs = get_error_attributes(None) + assert "error.type" in attrs + assert attrs["error.type"] == "NoneType" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From de75033d99f13241e2be27b1e4a91e04f0c07d09 Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Wed, 26 Nov 2025 04:13:19 +0000 Subject: [PATCH 03/11] fix: Update homepage URL and remove redundant assertion Co-authored-by: ralf0131 <4397305+ralf0131@users.noreply.github.com> --- .../opentelemetry-instrumentation-google-adk/pyproject.toml | 2 +- .../tests/test_utils.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml index e82e2b50..25929cdb 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml @@ -45,7 +45,7 @@ instruments = [ ] [project.urls] -Homepage = "https://github.com/your-org/loongsuite-python-agent" +Homepage = "https://github.com/alibaba/loongsuite-python-agent" [project.entry-points.opentelemetry_instrumentor] google-adk = "opentelemetry.instrumentation.google_adk:GoogleAdkInstrumentor" diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py index 832dad1f..93185612 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py @@ -234,7 +234,6 @@ def test_get_error_attributes_basic(self): attrs = get_error_attributes(error) assert attrs["error.type"] == "ValueError" - assert "ValueError" in attrs["error.type"] def test_get_error_attributes_timeout(self): """Test getting error attributes for timeout.""" From b27cbcc27dd215958486b2849c337e8655ad2e68 Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Wed, 26 Nov 2025 14:15:47 +0800 Subject: [PATCH 04/11] fix: Add missing package.py for google-adk instrumentation - Add package.py to define instrumentation metadata - Update instrumentation-genai/README.md with google-adk entry - Fixes CI generate task failure Change-Id: I2534d42f3d6538a4842d3b19b449e927685371e3 Co-developed-by: Cursor --- instrumentation-genai/README.md | 1 + .../instrumentation/google_adk/package.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py diff --git a/instrumentation-genai/README.md b/instrumentation-genai/README.md index 1b8f5e49..edb1ea5c 100644 --- a/instrumentation-genai/README.md +++ b/instrumentation-genai/README.md @@ -1,6 +1,7 @@ | Instrumentation | Supported Packages | Metrics support | Semconv status | | --------------- | ------------------ | --------------- | -------------- | +| [opentelemetry-instrumentation-google-adk](./opentelemetry-instrumentation-google-adk) | google-adk >= 0.1.0 | Yes | experimental | [opentelemetry-instrumentation-google-genai](./opentelemetry-instrumentation-google-genai) | google-genai >= 1.0.0 | No | development | [opentelemetry-instrumentation-langchain](./opentelemetry-instrumentation-langchain) | langchain >= 0.3.21 | No | development | [opentelemetry-instrumentation-openai-agents-v2](./opentelemetry-instrumentation-openai-agents-v2) | openai-agents >= 0.3.3 | No | development diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py new file mode 100644 index 00000000..e5d1b4c0 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py @@ -0,0 +1,21 @@ +# 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" + From fd0733251b3d2019d898131bae55625ea17a20af Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Wed, 26 Nov 2025 14:20:51 +0800 Subject: [PATCH 05/11] chore: Update bootstrap_gen.py to include google-adk instrumentation - Add google-adk >= 0.1.0 to libraries list - Generated by running scripts/generate_instrumentation_bootstrap.py - Fixes CI generate check failure Change-Id: I9c80405105cc795c8a2c66d7bb02b5e1934e81e9 Co-developed-by: Cursor --- .../src/opentelemetry/instrumentation/bootstrap_gen.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 2ea6f1bf..08d66b8c 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -16,6 +16,10 @@ # RUN `python scripts/generate_instrumentation_bootstrap.py` TO REGENERATE. libraries = [ + { + "library": "google-adk >= 0.1.0", + "instrumentation": "opentelemetry-instrumentation-google-adk==0.1.0", + }, { "library": "openai >= 1.26.0", "instrumentation": "opentelemetry-instrumentation-openai-v2", From 1164badc399a7bb5805d6c3597436a596e4aadba Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Wed, 26 Nov 2025 14:35:33 +0800 Subject: [PATCH 06/11] refactor: Move google-adk instrumentation to instrumentation-loongsuite - Rename from opentelemetry-instrumentation-google-adk to loongsuite-instrumentation-google-adk - Move from instrumentation-genai/ to instrumentation-loongsuite/ - Update pyproject.toml with loongsuite naming conventions - Change package name to loongsuite-instrumentation-google-adk - Use dynamic version from version.py - Update authors to LoongSuite Python Agent Authors - Update homepage URL to point to specific subdirectory - Update instrumentation-genai/README.md (remove google-adk entry) - Update instrumentation-loongsuite/README.md (add google-adk entry) - Update bootstrap_gen.py (remove google-adk as it's now loongsuite-specific) This aligns google-adk with other loongsuite-specific instrumentations. Change-Id: Ia1c1807731cb5ba8ba8ada0632a749dedd8a453b Co-developed-by: Cursor --- instrumentation-genai/README.md | 1 - instrumentation-loongsuite/README.md | 1 + .../README.md | 0 .../docs/ARMS_GOOGLE_ADK_USER_GUIDE.md | 894 ++++++++++++++++++ .../docs/MIGRATION_SUMMARY.md | 288 ++++++ .../docs/migration-plan.md | 220 +++++ .../docs/trace-metrics-comparison.md | 674 +++++++++++++ .../examples/adk_app.py | 87 ++ .../examples/adk_app_service.py | 122 +++ .../examples/main.py | 0 .../examples/simple_demo.py | 212 +++++ .../examples/tools.py | 0 .../pyproject.toml | 15 +- .../src/opentelemetry/__init__.py | 0 .../opentelemetry/instrumentation/__init__.py | 0 .../instrumentation/google_adk/__init__.py | 0 .../google_adk/internal/__init__.py | 0 .../google_adk/internal/_extractors.py | 0 .../google_adk/internal/_metrics.py | 0 .../google_adk/internal/_plugin.py | 0 .../google_adk/internal/_utils.py | 0 .../instrumentation/google_adk/package.py | 0 .../instrumentation/google_adk/version.py | 0 .../tests/__init__.py | 0 .../tests/test_metrics.py | 0 .../tests/test_plugin_integration.py | 0 .../tests/test_utils.py | 0 .../instrumentation/bootstrap_gen.py | 4 - 28 files changed, 2507 insertions(+), 11 deletions(-) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/README.md (100%) create mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/ARMS_GOOGLE_ADK_USER_GUIDE.md create mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/MIGRATION_SUMMARY.md create mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/migration-plan.md create mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/trace-metrics-comparison.md create mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app.py create mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app_service.py rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/examples/main.py (100%) create mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/simple_demo.py rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/examples/tools.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/pyproject.toml (81%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/__init__.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/__init__.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/google_adk/__init__.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/google_adk/internal/__init__.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/google_adk/internal/_utils.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/google_adk/package.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/src/opentelemetry/instrumentation/google_adk/version.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/tests/__init__.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/tests/test_metrics.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/tests/test_plugin_integration.py (100%) rename {instrumentation-genai/opentelemetry-instrumentation-google-adk => instrumentation-loongsuite/loongsuite-instrumentation-google-adk}/tests/test_utils.py (100%) diff --git a/instrumentation-genai/README.md b/instrumentation-genai/README.md index edb1ea5c..1b8f5e49 100644 --- a/instrumentation-genai/README.md +++ b/instrumentation-genai/README.md @@ -1,7 +1,6 @@ | Instrumentation | Supported Packages | Metrics support | Semconv status | | --------------- | ------------------ | --------------- | -------------- | -| [opentelemetry-instrumentation-google-adk](./opentelemetry-instrumentation-google-adk) | google-adk >= 0.1.0 | Yes | experimental | [opentelemetry-instrumentation-google-genai](./opentelemetry-instrumentation-google-genai) | google-genai >= 1.0.0 | No | development | [opentelemetry-instrumentation-langchain](./opentelemetry-instrumentation-langchain) | langchain >= 0.3.21 | No | development | [opentelemetry-instrumentation-openai-agents-v2](./opentelemetry-instrumentation-openai-agents-v2) | openai-agents >= 0.3.3 | No | development diff --git a/instrumentation-loongsuite/README.md b/instrumentation-loongsuite/README.md index 53a89c7c..0eccb23c 100644 --- a/instrumentation-loongsuite/README.md +++ b/instrumentation-loongsuite/README.md @@ -4,5 +4,6 @@ | [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 \ No newline at end of file diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/README.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/README.md rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/ARMS_GOOGLE_ADK_USER_GUIDE.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/ARMS_GOOGLE_ADK_USER_GUIDE.md new file mode 100644 index 00000000..a0bc29f5 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/ARMS_GOOGLE_ADK_USER_GUIDE.md @@ -0,0 +1,894 @@ +# 使用 ARMS Python 探针监控 Google ADK 应用 + +更新时间:2025-10-24 + +## 背景信息 + +Google ADK (Agent Development Kit) 是 Google 推出的用于构建 GenAI Agent 应用的开发框架。通过 Google ADK,开发者可以快速构建具有工具调用、多轮对话、状态管理等能力的智能 Agent 应用。 + +ARMS Python 探针是阿里云应用实时监控服务(ARMS)自研的 Python 语言可观测采集探针,基于 OpenTelemetry 标准实现了自动化埋点能力,完整支持 Google ADK 应用的追踪和监控。 + +将 Google ADK 应用接入 ARMS 后,您可以: +- 查看 Agent 调用链视图,直观分析 Agent 的执行流程 +- 监控工具调用(Tool Call)的输入输出和执行耗时 +- 追踪 LLM 模型请求的详细信息,包括 Token 消耗、响应时间等 +- 实时监控应用性能指标,及时发现和定位问题 +- 追踪 A2A 通讯的细节 + +ARMS 支持的 LLM(大语言模型)推理服务框架和应用框架,请参见 [ARMS 应用监控支持的 Python 组件和框架](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/install-arms-agent-for-python-applications-deployed-in-ack-and-acs)。 + +## 前提条件 + +- 已开通 ARMS 服务。如未开通,请参见[开通 ARMS 服务](https://help.aliyun.com/zh/arms/application-monitoring/getting-started/activate-arms)。 +- 已安装 Python 3.8 及以上版本。 +- 已安装 Google ADK(`google-adk>=0.1.0`)。 + +## 安装 ARMS Python 探针 + +根据 Google ADK 应用部署环境选择合适的安装方式: + +### 容器环境安装 + +如果您的应用部署在容器服务 ACK 或容器计算服务 ACS 上,可以通过 ack-onepilot 组件自动安装 ARMS Python 探针。具体操作,请参见[通过 ack-onepilot 组件安装 Python 探针](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/install-the-arms-agent-for-python-applications-deployed-in-container-service-for-kubernetes)。 + +### 手动安装 + +1. 安装 ARMS Python 探针: + +```bash +pip install aliyun-bootstrap +``` + +2. 安装 Google ADK 及相关依赖: + +```bash +# 安装 Google ADK +pip install google-adk>=0.1.0 + +# 安装 LLM 客户端库(根据实际使用选择) +pip install litellm # 用于统一的 LLM API 调用 +``` + +## 接入 ARMS + +### 启动应用 + +使用 ARMS Python 探针启动您的 Google ADK 应用: + +```bash +aliyun-instrument python your_adk_app.py +``` + +**说明**: +- 将 `your_adk_app.py` 替换为您的实际应用入口文件。 +- ARMS Python 探针会自动识别 Google ADK 应用并进行埋点。 +- 如果您暂时没有可接入的 Google ADK 应用,可以使用本文档附录提供的应用 Demo。 + +### 配置环境变量 + +在启动应用前,您可以配置以下环境变量: + +```bash +# ARMS 接入配置 +export ARMS_APP_NAME=xxx # 应用名称。 +export ARMS_REGION_ID=xxx # 对应的阿里云账号的RegionID。 +export ARMS_LICENSE_KEY=xxx # 阿里云 LicenseKey。 + +# GenAI 相关配置 +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + +# 启动应用 +aliyun-instrument python your_adk_app.py +``` + +**配置说明**: +- `APSARA_APM_ACCESS_KEY_ID`:您的阿里云 AccessKey ID +- `APSARA_APM_ACCESS_KEY_SECRET`:您的阿里云 AccessKey Secret +- `APSARA_APM_REGION_ID`:ARMS 服务所在地域,例如 `cn-hangzhou` +- `APSARA_APM_SERVICE_NAME`:应用名称,用于在 ARMS 控制台中标识您的应用 + +## 执行结果 + +约一分钟后,若 Google ADK 应用出现在 ARMS 控制台的 **LLM 应用监控** > **应用列表** 页面中且有数据上报,则说明接入成功。 + + +**图 1:ARMS 控制台 - LLM 应用列表** + +[预留截图位置] + +--- + +## 查看监控数据 + +### 调用链视图 + +在 ARMS 控制台的 **LLM 应用监控** > **调用链** 页面,您可以查看 Google ADK 应用的详细调用链路: + + +**图 2:Google ADK 应用调用链列表** + +[预留截图位置] + +--- + +点击具体的调用链,可以查看完整的 Span 信息,包括: + +- **Agent Span**:Agent 执行的完整流程 + - `gen_ai.operation.name`: `invoke_agent` + - `gen_ai.agent.name`: Agent 名称 + - `gen_ai.agent.description`: Agent 描述 + - `gen_ai.conversation.id`: 会话 ID + - `enduser.id`: 用户 ID + +- **LLM Span**:模型调用详情 + - `gen_ai.operation.name`: `chat` + - `gen_ai.provider.name`: 模型提供商 + - `gen_ai.request.model`: 请求模型名称 + - `gen_ai.response.model`: 响应模型名称 + - `gen_ai.usage.input_tokens`: 输入 Token 数 + - `gen_ai.usage.output_tokens`: 输出 Token 数 + - `gen_ai.response.finish_reasons`: 完成原因 + +- **Tool Span**:工具调用详情 + - `gen_ai.operation.name`: `execute_tool` + - `gen_ai.tool.name`: 工具名称 + - `gen_ai.tool.description`: 工具描述 + - `gen_ai.tool.call.arguments`: 工具调用参数 + - `gen_ai.tool.call.result`: 工具返回结果 + + +**图 3:调用链详情 - 展示 Agent、LLM、Tool 的层级关系** + +[预留截图位置] + +--- + +### 性能指标 + +在 **LLM 应用监控** > **指标** 页面,您可以查看应用的性能指标: + +#### 调用次数(genai_calls_count) + +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:次 +- **维度**: + - `modelName`:模型名称 + - `spanKind`:Span 类型(LLM、AGENT、TOOL) + - `service`:服务名称 + - `rpc`:调用名称 + + +**图 4:GenAI 调用次数统计** + +[预留截图位置] + +--- + +#### 响应耗时(genai_calls_duration_seconds) + +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:秒 +- **维度**: + - `modelName`:模型名称 + - `spanKind`:Span 类型(LLM、AGENT、TOOL) + - `service`:服务名称 + - `rpc`:调用名称 + + +**图 5:GenAI 响应耗时分布** + +[预留截图位置] + +--- + +#### Token 使用量(genai_llm_usage_tokens) + +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:token +- **维度**: + - `modelName`:模型名称 + - `spanKind`:Span 类型(通常为 LLM) + - `usageType`:Token 类型(input、output) + - `service`:服务名称 + - `rpc`:调用名称 + + +**图 6:Token 使用量统计** + +[预留截图位置] + +--- + +#### 首包响应时间(genai_llm_first_token_seconds) + +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:秒 +- **说明**:从 LLM 请求发出到收到第一个 Token 的耗时(TTFT - Time To First Token) +- **维度**: + - `modelName`:模型名称 + - `spanKind`:Span 类型(LLM) + - `service`:服务名称 + - `rpc`:调用名称 + + +**图 7:LLM 首包响应时间** + +[预留截图位置] + +--- + +#### 错误统计(genai_calls_error_count) + +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:次 +- **维度**: + - `modelName`:模型名称 + - `spanKind`:Span 类型(LLM、AGENT、TOOL) + - `service`:服务名称 + - `rpc`:调用名称 + + +**图 8:GenAI 错误统计** + +[预留截图位置] + +--- + +#### 慢调用统计(genai_calls_slow_count) + +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:次 +- **维度**: + - `modelName`:模型名称 + - `spanKind`:Span 类型(LLM、AGENT、TOOL) + - `service`:服务名称 + - `rpc`:调用名称 + + +**图 9:GenAI 慢调用统计** + +[预留截图位置] + +--- + +### LLM 调用链分析 + +ARMS 提供专门的 LLM 调用链分析功能,支持: + +- **输入输出分析**:查看每次 LLM 调用的完整 prompt 和 response +- **Token 成本分析**:统计和分析 Token 消耗情况 +- **性能分析**:分析响应时间、首 Token 时间等性能指标 +- **错误分析**:快速定位和诊断 LLM 调用错误 + +更多信息,请参见 [LLM 调用链分析](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/llm-call-chain-analysis)。 + + +**图 6:LLM 调用链分析** + +[预留截图位置] + +--- + +## 配置选项 + +### 输入/输出内容采集 + +**默认值**:`False`,默认不采集详细内容。 + +**配置说明**: +- 开启后:采集 Agent、Tool、LLM 的完整输入输出内容 +- 关闭后:仅采集字段大小,不采集字段内容 + +**配置方式**: + +```bash +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true +``` + +**注意**:采集内容可能包含敏感信息,请根据实际需求和安全要求决定是否开启。 + +### 消息内容字段长度限制 + +**默认值**:4096 字符 + +**配置说明**:限制每条消息内容的最大长度,超过限制的内容将被截断。 + +**配置方式**: + +```bash +export OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH=8192 +``` + +### Span 属性值长度限制 + +**默认值**:无限制 + +**配置说明**:限制上报的 Span 属性值(如 `gen_ai.agent.description`)的长度,超过限制的内容将被截断。 + +**配置方式**: + +```bash +export OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT=4096 +``` + +### 应用类型指定 + +ARMS Python 探针会自动识别应用类型,但您也可以手动指定: + +```bash +# app: 大语言模型应用 +export APSARA_APM_APP_TYPE=app +``` + +## 语义规范说明 + +ARMS Python 探针完全遵循 OpenTelemetry GenAI 语义规范,确保监控数据的标准化和可移植性。 + +### Trace 语义规范 + +**Span 命名规范**: +- LLM 操作:`chat {model}`,例如 `chat gemini-pro` +- Agent 操作:`invoke_agent {agent_name}`,例如 `invoke_agent math_tutor` +- Tool 操作:`execute_tool {tool_name}`,例如 `execute_tool get_weather` + +**标准 Attributes**: +- `gen_ai.operation.name`:操作类型(必需) +- `gen_ai.provider.name`:提供商名称(必需) +- `gen_ai.conversation.id`:会话 ID(替代旧版 `gen_ai.session.id`) +- `enduser.id`:用户 ID(替代旧版 `gen_ai.user.id`) +- `gen_ai.response.finish_reasons`:完成原因(数组格式) + +更多信息,请参见: +- [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) + +### ARMS 监控指标 + +ARMS Python 探针会自动采集以下 GenAI 相关指标: + +#### 1. genai_calls_count +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:次 +- **说明**:各种 GenAI 相关调用的请求次数 +- **维度**: + - `modelName`:模型名称(必需) + - `spanKind`:Span 类型(必需),如 `LLM`、`AGENT`、`TOOL` + - `pid`:应用 ID + - `service`:服务名称 + - `serverIp`:机器 IP + - `rpc`:调用名称(spanName) + +#### 2. genai_calls_duration_seconds +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:秒 +- **说明**:各种 GenAI 相关调用的响应耗时 +- **维度**: + - `modelName`:模型名称(必需) + - `spanKind`:Span 类型(必需) + - 以及其他公共维度(pid、service、serverIp、rpc) + +#### 3. genai_calls_error_count +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:次 +- **说明**:各种 GenAI 相关调用的错误次数 +- **维度**: + - `modelName`:模型名称(必需) + - `spanKind`:Span 类型(必需) + - 以及其他公共维度(pid、service、serverIp、rpc) + +#### 4. genai_calls_slow_count +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:次 +- **说明**:各种 GenAI 相关调用的慢调用次数 +- **维度**: + - `modelName`:模型名称(必需) + - `spanKind`:Span 类型(必需) + - 以及其他公共维度(pid、service、serverIp、rpc) + +#### 5. genai_llm_first_token_seconds +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:秒 +- **说明**:调用 LLM 首包响应耗时(从请求到第一个响应返回的耗时) +- **适用范围**:大模型应用和模型服务 +- **维度**: + - `modelName`:模型名称(必需) + - `spanKind`:Span 类型(必需) + - 以及其他公共维度(pid、service、serverIp、rpc) + +#### 6. genai_llm_usage_tokens +- **指标类型**:Gauge +- **采集间隔**:1 分钟 +- **单位**:token +- **说明**:Tokens 消耗统计 +- **维度**: + - `modelName`:模型名称(必需) + - `spanKind`:Span 类型(必需) + - `usageType`:用途类型(必需),取值为 `input` 或 `output` + - 以及其他公共维度(pid、service、serverIp、rpc) + +#### 公共维度说明 + +所有 GenAI 指标都包含以下公共维度: + +| 维度Key | 维度描述 | 类型 | 示例 | 需求等级 | +|--------|---------|------|------|---------| +| `pid` | 应用 ID | string | `ggxw4lnjuz@0cb8619bb54****` | 必须 | +| `service` | 服务名称 | string | `llm-rag-demo` | 必须 | +| `serverIp` | 应用对应机器 IP | string | `127.0.0.1` | 可选 | +| `rpc` | 调用名称(spanName),工具调用为 toolName | string | `/query` | 必须 | +| `source` | 用户来源 | string | `apm` | 必须 | +| `acs_cms_workspace` | 云监控 Workspace | string | `arms-test` | 有条件时必须 | +| `acs_arms_service_id` | 云监控服务 ID | string | `ggxw4lnjuz@b63ba5a1d60b517ae374f` | 有条件时必须 | + +**注意**: +- `source` 取值为 `apm`(ARMS 应用实时监控服务)或 `xtrace`(可观测链路 OpenTelemetry 版) +- `spanKind` 用于区分不同类型的 GenAI 操作:`LLM`(大模型调用)、`AGENT`(Agent 调用)、`TOOL`(工具调用)等 +- 所有指标均为大模型调用记录为内部调用(CallType: `internal`),通过 `spanKind` 进行聚合 + +## 附录:Demo 示例 + +### 示例程序架构流程图 + +本章节的示例程序基于 Google ADK 框架,实现了一个完整的工具使用 Agent HTTP 服务。以下是其核心执行流程: + +```mermaid +sequenceDiagram + autonumber + participant User as 👤 用户/客户端 + participant FastAPI as 🌐 FastAPI 服务 + participant Runner as 🏃 ADK Runner + participant Agent as 🤖 LLM Agent + participant LLM as 🧠 百炼模型
(qwen-plus) + participant Tools as 🔧 工具集 + participant ARMS as 📊 ARMS 监控平台 + + Note over FastAPI,ARMS: ARMS Python 探针自动注入
捕获所有 trace 和 metrics 数据 + + User->>FastAPI: POST /tools
{task: "现在几点了?"} + activate FastAPI + + FastAPI->>Runner: 调用 run_async() + activate Runner + + Runner->>Agent: 创建用户消息 + activate Agent + + Agent->>LLM: 发送任务给 LLM 模型 + activate LLM + Note over LLM: LLM 理解任务
决定需要调用工具 + LLM-->>Agent: 返回工具调用决策
execute_tool("get_current_time") + deactivate LLM + + Agent->>Tools: 调用 get_current_time() + activate Tools + Note over Tools: 执行工具函数
获取系统时间 + Tools-->>Agent: 返回当前时间结果 + deactivate Tools + + Agent->>LLM: 发送工具结果给 LLM + activate LLM + Note over LLM: LLM 整合工具结果
生成最终回答 + LLM-->>Agent: 返回最终答案 + deactivate LLM + + Agent-->>Runner: 返回对话结果 + deactivate Agent + + Runner-->>FastAPI: 返回响应内容 + deactivate Runner + + FastAPI-->>User: 返回 JSON 响应
{success: true, data: {...}} + deactivate FastAPI + + Note over ARMS: 📊 ARMS 自动捕获:
✅ Span:LLM 请求、Agent 调用、Tool 执行
✅ Metrics:操作耗时、Token 消耗
✅ Trace:完整的调用链路 +``` + +**流程说明:** + +1. **用户请求**:客户端通过 HTTP POST 请求发送任务到 FastAPI 服务(如"现在几点了?") +2. **ADK Runner 处理**:Runner 接收请求并创建用户消息 +3. **Agent 协调**:Agent 将任务发送给 LLM 模型进行理解 +4. **LLM 决策**:LLM 分析任务并决定需要调用 `get_current_time()` 工具 +5. **工具执行**:Agent 调用相应的工具函数获取当前时间 +6. **结果整合**:Agent 将工具返回的结果再次发送给 LLM +7. **生成回答**:LLM 基于工具结果生成最终的自然语言回答 +8. **响应返回**:完整的响应通过 FastAPI 返回给客户端 +9. **ARMS 监控**:整个过程中,ARMS Python 探针自动捕获所有的 Trace、Span 和 Metrics 数据 + +**可用工具集:** + +本示例程序集成了 7 个工具函数,展示了 Agent 的多种能力: + +| 工具名称 | 功能描述 | 示例任务 | +|---------|---------|---------| +| 🕐 `get_current_time` | 获取当前时间 | "现在几点了?" | +| 🧮 `calculate_math` | 数学表达式计算 | "计算 123 * 456" | +| 🎲 `roll_dice` | 掷骰子(可指定面数) | "掷一个六面骰子" | +| 🔢 `check_prime_numbers` | 质数检查 | "检查 17, 25, 29 是否为质数" | +| 🌤️ `get_weather_info` | 获取天气信息(模拟) | "北京的天气怎么样?" | +| 🔍 `search_web` | 网络搜索(模拟) | "搜索人工智能的定义" | +| 🌍 `translate_text` | 文本翻译(模拟) | "翻译'你好'成英文" | + +**ARMS 监控维度:** + +探针会自动为以下操作生成对应的 Span 和 Metrics: + +**Span 数据:** +- **LLM 请求 Span**:包含模型名称、Token 消耗、响应时间等 +- **Agent 调用 Span**:包含 Agent 名称、操作类型、会话 ID 等 +- **Tool 执行 Span**:包含工具名称、参数、返回值等 + +**Metrics 数据:** +- **genai_calls_count**:GenAI 调用请求次数(按 spanKind 区分:LLM、AGENT、TOOL) +- **genai_calls_duration_seconds**:GenAI 调用响应耗时 +- **genai_calls_error_count**:GenAI 调用错误次数 +- **genai_calls_slow_count**:GenAI 慢调用次数 +- **genai_llm_first_token_seconds**:LLM 首包响应耗时(TTFT) +- **genai_llm_usage_tokens**:Token 消耗统计(区分 input/output) + +完整的示例代码请参见项目的 `examples/` 目录([main.py](../examples/main.py) 和 [tools.py](../examples/tools.py))。 + +### Google ADK 基础示例 + +本示例演示如何创建一个简单的 Google ADK Agent 应用。 + +#### 应用代码(adk_app.py) + +```python +""" +Google ADK Demo Application +演示 Agent、Tool、LLM 的集成使用 +""" +from google.adk.agents import Agent +from google.adk.tools import Tool, FunctionTool +from google.adk.runners import Runner +from datetime import datetime +import json + + +# 定义工具函数 +def get_current_time() -> str: + """获取当前时间""" + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def calculate(expression: str) -> str: + """ + 计算数学表达式 + + Args: + expression: 数学表达式,例如 "2 + 3" + """ + try: + result = eval(expression) + return f"计算结果:{result}" + except Exception as e: + return f"计算错误:{str(e)}" + + +# 创建 Tools +time_tool = FunctionTool( + name="get_current_time", + description="获取当前时间", + func=get_current_time +) + +calculator_tool = FunctionTool( + name="calculate", + description="计算数学表达式,支持加减乘除等基本运算", + func=calculate +) + +# 创建 Agent +math_assistant = Agent( + name="math_assistant", + description="一个能够执行数学计算和查询时间的智能助手", + tools=[time_tool, calculator_tool], + model="gemini-1.5-flash", # 或使用其他支持的模型 + system_instruction="你是一个专业的数学助手,可以帮助用户进行计算和查询时间。" +) + +# 创建 Runner +runner = Runner(agent=math_assistant) + + +def main(): + """主函数""" + print("Google ADK Demo - Math Assistant") + print("=" * 50) + + # 测试场景 1:计算 + print("\n场景 1:数学计算") + result1 = runner.run("帮我计算 (125 + 375) * 2 的结果") + print(f"用户:帮我计算 (125 + 375) * 2 的结果") + print(f"助手:{result1}") + + # 测试场景 2:查询时间 + print("\n场景 2:查询时间") + result2 = runner.run("现在几点了?") + print(f"用户:现在几点了?") + print(f"助手:{result2}") + + # 测试场景 3:组合使用 + print("\n场景 3:组合使用") + result3 = runner.run("现在几点了?顺便帮我算一下 100 / 4") + print(f"用户:现在几点了?顺便帮我算一下 100 / 4") + print(f"助手:{result3}") + + print("\n" + "=" * 50) + print("Demo 完成") + + +if __name__ == "__main__": + main() +``` + +#### 依赖文件(requirements.txt) + +```txt +google-adk>=0.1.0 +litellm +aliyun-python-agent +``` + +#### 运行方式 + +```bash +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 配置 ARMS 环境变量 +export APSARA_APM_ACCESS_KEY_ID=<您的AccessKey ID> +export APSARA_APM_ACCESS_KEY_SECRET=<您的AccessKey Secret> +export APSARA_APM_REGION_ID=cn-hangzhou +export APSARA_APM_SERVICE_NAME=google-adk-demo + +# 3. 配置 GenAI 内容采集 +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + +# 4. 配置模型 API(根据使用的模型选择) +export GEMINI_API_KEY=<您的 Gemini API Key> +# 或使用 DashScope +export DASHSCOPE_API_KEY=<您的 DashScope API Key> + +# 5. 使用 ARMS 探针启动应用 +aliyun-instrument python adk_app.py +``` + +### Google ADK + FastAPI 服务示例 + +本示例演示如何将 Google ADK Agent 封装为 Web API 服务。 + +#### 应用代码(adk_api_service.py) + +```python +""" +Google ADK + FastAPI Service +将 Google ADK Agent 封装为 RESTful API 服务 +""" +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from google.adk.agents import Agent +from google.adk.tools import FunctionTool +from google.adk.runners import Runner +import uvicorn +from datetime import datetime + + +# 定义请求和响应模型 +class ChatRequest(BaseModel): + message: str + session_id: str = None + user_id: str = None + + +class ChatResponse(BaseModel): + response: str + session_id: str + token_usage: dict = None + + +# 创建 FastAPI 应用 +app = FastAPI(title="Google ADK API Service") + + +# 定义工具 +def get_weather(city: str) -> str: + """获取城市天气(模拟)""" + # 实际应用中这里应该调用真实的天气API + return f"{city}的天气:晴,温度25°C" + + +def search_knowledge(query: str) -> str: + """搜索知识库(模拟)""" + # 实际应用中这里应该连接真实的知识库 + return f"关于'{query}'的知识:这是模拟的知识库返回结果" + + +# 创建 Tools +weather_tool = FunctionTool( + name="get_weather", + description="获取指定城市的天气信息", + func=get_weather +) + +knowledge_tool = FunctionTool( + name="search_knowledge", + description="搜索内部知识库", + func=search_knowledge +) + +# 创建 Agent +assistant_agent = Agent( + name="customer_service_agent", + description="智能客服助手,可以查询天气和搜索知识库", + tools=[weather_tool, knowledge_tool], + model="gemini-1.5-flash", + system_instruction="你是一个专业的客服助手,态度友好,回答准确。" +) + +# 创建 Runner +runner = Runner(agent=assistant_agent) + + +# API 端点 +@app.get("/") +def root(): + """健康检查""" + return { + "service": "Google ADK API Service", + "status": "running", + "timestamp": datetime.now().isoformat() + } + + +@app.post("/chat", response_model=ChatResponse) +def chat(request: ChatRequest): + """ + 处理聊天请求 + + Args: + request: 包含用户消息和会话信息的请求 + + Returns: + ChatResponse: 包含 Agent 响应的结果 + """ + try: + # 执行 Agent + response = runner.run( + request.message, + session_id=request.session_id, + user_id=request.user_id + ) + + return ChatResponse( + response=response, + session_id=request.session_id or "default", + token_usage={"note": "Token usage info would be here"} + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/health") +def health(): + """健康检查端点""" + return {"status": "healthy"} + + +if __name__ == "__main__": + # 启动服务 + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + log_level="info" + ) +``` + +#### 依赖文件(requirements.txt) + +```txt +google-adk>=0.1.0 +fastapi +uvicorn[standard] +pydantic +litellm +aliyun-python-agent +``` + +#### 运行方式 + +```bash +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 配置环境变量 +export APSARA_APM_ACCESS_KEY_ID=<您的AccessKey ID> +export APSARA_APM_ACCESS_KEY_SECRET=<您的AccessKey Secret> +export APSARA_APM_REGION_ID=cn-hangzhou +export APSARA_APM_SERVICE_NAME=google-adk-api-service +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true +export GEMINI_API_KEY=<您的 Gemini API Key> + +# 3. 使用 ARMS 探针启动服务 +aliyun-instrument python adk_api_service.py +``` + +#### 测试 API + +```bash +# 测试健康检查 +curl http://localhost:8000/health + +# 测试聊天接口 +curl -X POST http://localhost:8000/chat \ + -H "Content-Type: application/json" \ + -d '{ + "message": "北京今天天气怎么样?", + "session_id": "session_001", + "user_id": "user_123" + }' +``` + +## 常见问题 + +### 1. 应用未出现在 ARMS 控制台 + +**问题排查**: +- 检查 AccessKey 配置是否正确 +- 检查地域(Region ID)配置是否正确 +- 检查网络连接,确保应用可以访问 ARMS 服务端点 +- 查看应用日志,确认探针是否正常启动 + +### 2. 调用链数据缺失 + +**问题排查**: +- 检查 `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` 配置 +- 确认 Google ADK 版本是否符合要求(>=0.1.0) +- 检查是否有异常或错误日志 + +### 3. Token 使用量数据为空 + +**可能原因**: +- 部分模型可能不返回 Token 使用量信息 +- 需要确保模型 API 响应中包含 usage 信息 + +### 4. 性能影响 + +**说明**: +- ARMS Python 探针采用异步上报机制,对应用性能影响极小(通常 < 1%) +- 如需进一步降低影响,可以关闭内容采集:`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false` + +## 相关文档 + +- [ARMS 应用监控概述](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/application-monitoring-overview) +- [LLM 调用链分析](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/llm-call-chain-analysis) +- [ARMS Python 探针总览](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/use-the-arms-agent-for-python-to-monitor-llm-applications) +- [OpenTelemetry GenAI 语义规范](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/) +- [Google ADK 官方文档](https://google.github.io/adk-docs/) + +## 技术支持 + +如果您在使用过程中遇到问题,可以通过以下方式获取帮助: + +- 提交工单:在阿里云控制台提交技术支持工单 +- 钉钉群:加入 ARMS 技术交流群 +- 文档反馈:通过文档页面的反馈按钮提交问题 + +--- + +**最后更新时间**:2025-10-24 +**文档版本**:v1.0 + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/MIGRATION_SUMMARY.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/MIGRATION_SUMMARY.md new file mode 100644 index 00000000..2da4acbb --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/MIGRATION_SUMMARY.md @@ -0,0 +1,288 @@ +# Google ADK 插件迁移总结 + +## 迁移完成状态 + +✅ **所有 6 个阶段已完成!** + +--- + +## 📋 迁移概览 + +### 已完成的阶段 + +| 阶段 | 状态 | 说明 | +|------|------|------| +| **Phase 1: Trace 核心变更** | ✅ 完成 | `gen_ai.system` → `gen_ai.provider.name`
移除 `gen_ai.span.kind`
移除 `gen_ai.framework` | +| **Phase 2: Trace 属性标准化** | ✅ 完成 | Agent/Tool 属性标准化
`session.id` → `conversation.id`
`user.id` → `enduser.id` | +| **Phase 3: 内容捕获机制** | ✅ 完成 | 实现标准 `process_content()`
环境变量控制
移除 ARMS SDK 依赖 | +| **Phase 4: Metrics 完全重构** | ✅ 完成 | 12 个指标 → 2 个标准指标
所有维度标准化
移除高基数属性 | +| **Phase 5: 测试重写** | ✅ 完成 | Extractors 测试
Metrics 测试 | +| **Phase 6: 文档和示例** | ✅ 完成 | README.md
迁移对比文档 | + +--- + +## 🎯 关键变更总结 + +### 1. 命名空间变更 + +```python +# ❌ 商业版本 +from aliyun.instrumentation.google_adk import AliyunGoogleAdkInstrumentor + +# ✅ 开源版本 +from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor +``` + +### 2. 核心属性变更 + +| 商业版本 | 开源版本 | 状态 | +|---------|---------|------| +| `gen_ai.system` | `gen_ai.provider.name` | ✅ 已修改 | +| `gen_ai.span.kind` | (removed) | ✅ 已移除 | +| `gen_ai.framework` | (removed) | ✅ 已移除 | +| `gen_ai.session.id` | `gen_ai.conversation.id` | ✅ 已修改 | +| `gen_ai.user.id` | `enduser.id` | ✅ 已修改 | +| `gen_ai.model_name` | (removed) | ✅ 已移除 | +| `gen_ai.response.finish_reason` | `gen_ai.response.finish_reasons` | ✅ 已修改 | +| `gen_ai.usage.total_tokens` | (removed) | ✅ 已移除 | +| `gen_ai.request.is_stream` | (removed) | ✅ 已移除 | + +### 3. Agent/Tool 属性变更 + +| 商业版本 | 开源版本 | 状态 | +|---------|---------|------| +| `agent.name` | `gen_ai.agent.name` | ✅ 已修改 | +| `agent.description` | `gen_ai.agent.description` | ✅ 已修改 | +| `tool.name` | `gen_ai.tool.name` | ✅ 已修改 | +| `tool.description` | `gen_ai.tool.description` | ✅ 已修改 | +| `tool.parameters` | `gen_ai.tool.call.arguments` | ✅ 已修改 | + +### 4. Metrics 变更 + +#### 移除的指标(12个 → 0个) + +❌ **ARMS 专有指标**: +- `calls_count` +- `calls_duration_seconds` +- `call_error_count` +- `llm_usage_tokens` +- `llm_first_token_seconds` + +❌ **自定义 GenAI 指标**: +- `genai_calls_count` +- `genai_calls_duration_seconds` +- `genai_calls_error_count` +- `genai_calls_slow_count` +- `genai_llm_first_token_seconds` +- `genai_llm_usage_tokens` +- `genai_avg_first_token_seconds` + +#### 新增的标准指标(0个 → 2个) + +✅ **标准 OTel GenAI Client Metrics**: +1. `gen_ai.client.operation.duration` (Histogram, unit: seconds) +2. `gen_ai.client.token.usage` (Histogram, unit: tokens) + +#### Metrics 维度变更 + +| 商业版本 | 开源版本 | 状态 | +|---------|---------|------| +| `callType` | (removed) | ✅ 已移除 | +| `callKind` | (removed) | ✅ 已移除 | +| `rpcType` | (removed) | ✅ 已移除 | +| `rpc` | (removed) | ✅ 已移除 | +| `modelName` | `gen_ai.request.model` | ✅ 已修改 | +| `spanKind` | `gen_ai.operation.name` | ✅ 已修改 | +| `usageType` | `gen_ai.token.type` | ✅ 已修改 | +| `session_id` | (removed from metrics) | ✅ 已移除 | +| `user_id` | (removed from metrics) | ✅ 已移除 | + +### 5. 环境变量变更 + +| 商业版本 | 开源版本 | +|---------|---------| +| `ENABLE_GOOGLE_ADK_INSTRUMENTOR` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | +| (SDK internal) | `OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH` | + +### 6. 内容捕获机制变更 + +```python +# ❌ 商业版本 - 依赖 ARMS SDK +from aliyun.sdk.extension.arms.utils.capture_content import process_content + +# ✅ 开源版本 - 自实现标准机制 +from ._utils import process_content # 基于环境变量控制 +``` + +--- + +## 📁 文件结构 + +### 开源版本文件结构 + +``` +opentelemetry-instrumentation-google-adk/ +├── src/ +│ └── opentelemetry/ +│ └── instrumentation/ +│ └── google_adk/ +│ ├── __init__.py # ✅ 主入口 (GoogleAdkInstrumentor) +│ ├── version.py # ✅ 版本信息 +│ └── internal/ +│ ├── __init__.py +│ ├── _plugin.py # ✅ GoogleAdkObservabilityPlugin +│ ├── _extractors.py # ✅ AdkAttributeExtractors +│ ├── _metrics.py # ✅ AdkMetricsCollector +│ └── _utils.py # ✅ 工具函数 +├── tests/ +│ ├── __init__.py +│ ├── test_extractors.py # ✅ 属性提取测试 +│ └── test_metrics.py # ✅ Metrics 测试 +├── docs/ +│ ├── trace-metrics-comparison.md # ✅ 详细对比文档 +│ ├── migration-plan.md # ✅ 迁移计划 +│ └── MIGRATION_SUMMARY.md # ✅ 迁移总结(本文档) +├── pyproject.toml # ✅ 项目配置 +└── README.md # ✅ 项目文档 +``` + +--- + +## 🎉 迁移成果 + +### 代码质量 + +- ✅ **100% 符合 OTel GenAI 语义规范**(最新版本) +- ✅ **移除所有 ARMS SDK 依赖** +- ✅ **标准化所有属性命名** +- ✅ **简化指标系统**(12 → 2 个指标) +- ✅ **测试覆盖核心功能** + +### 兼容性 + +- ✅ **与 openai-v2 插件一致**的实现模式 +- ✅ **可贡献到 OTel 官方仓库** +- ✅ **支持标准 OTel 环境变量** +- ✅ **遵循 OTel Python SDK 规范** + +### 文档完整性 + +- ✅ **README.md** - 完整的使用文档 +- ✅ **trace-metrics-comparison.md** - 详细的差异对比 +- ✅ **migration-plan.md** - 执行计划 +- ✅ **MIGRATION_SUMMARY.md** - 迁移总结(本文档) + +--- + +## 🔍 验证清单 + +### 代码验证 + +- [x] 所有 `gen_ai.system` 改为 `gen_ai.provider.name` +- [x] 移除所有 `gen_ai.span.kind` 引用 +- [x] 移除 `gen_ai.framework` 属性 +- [x] Agent/Tool 属性使用 `gen_ai.` 前缀 +- [x] `session.id` 改为 `conversation.id` +- [x] `user.id` 改为 `enduser.id` +- [x] 移除所有 12 个 ARMS 指标 +- [x] 实现 2 个标准 OTel 指标 +- [x] 移除指标中的高基数属性 +- [x] 实现标准内容捕获机制 +- [x] 移除 ARMS SDK 依赖 + +### 文档验证 + +- [x] README 包含使用说明 +- [x] 对比文档详细记录差异 +- [x] 测试文件验证关键变更 +- [x] 环境变量文档完整 + +--- + +## 📊 统计数据 + +### 代码变更统计 + +| 类别 | 商业版本 | 开源版本 | 变化 | +|------|---------|---------|------| +| **核心文件** | 6 | 6 | ➡️ 0 | +| **测试文件** | 0 (待创建) | 2 | ➕ 2 | +| **文档文件** | 2 | 4 | ➕ 2 | +| **依赖项** | ARMS SDK | 仅 OTel SDK | ✅ 简化 | +| **代码行数** | ~2500 | ~2000 | ⬇️ 20% | +| **指标数量** | 12 | 2 | ⬇️ 83% | + +### 属性变更统计 + +| 类别 | 变更数量 | 类型 | +|------|---------|------| +| **改名** | 8 | `gen_ai.system`, `session.id`, etc. | +| **移除** | 7 | `gen_ai.span.kind`, `framework`, etc. | +| **新增前缀** | 6 | Agent/Tool 属性 | +| **复数化** | 1 | `finish_reason` → `finish_reasons` | + +--- + +## 🚀 后续工作 + +### 可选的增强 + +1. **首包延迟支持** (可选) + - 当前:已移除(标准客户端规范中无此指标) + - 选项:作为自定义扩展添加 + +2. **更多测试用例** + - 当前:基础测试已完成 + - 增强:集成测试、端到端测试 + +3. **性能优化** + - 当前:功能完整 + - 增强:减少内存分配、优化 JSON 序列化 + +4. **示例代码** + - 当前:README 中有基础示例 + - 增强:完整的 examples/ 目录 + +### 贡献到 OTel 社区 + +- [ ] 提交 PR 到 opentelemetry-python-contrib +- [ ] 注册到 PyPI +- [ ] 添加到 OTel Registry + +--- + +## 📝 注意事项 + +### 非向后兼容的变更 + +⚠️ **这是一个全新的实现,与商业版本 API 不兼容** + +- ❌ 不能直接替换商业版本 +- ✅ 需要更新导入语句 +- ✅ 需要更新环境变量 +- ✅ 需要更新依赖项 + +### 迁移建议 + +1. **测试环境先行**:在测试环境完成迁移验证 +2. **监控对比**:对比迁移前后的指标变化 +3. **逐步迁移**:分批次迁移生产环境 +4. **文档同步**:更新内部文档和运维手册 + +--- + +## 📧 联系方式 + +如有问题,请: + +- 📖 查阅 [README.md](../README.md) +- 🐛 提交 [Issue](https://github.com/your-org/loongsuite-python-agent/issues) +- 💬 参与 [Discussions](https://github.com/your-org/loongsuite-python-agent/discussions) + +--- + +**迁移完成日期**: 2025-10-21 +**迁移版本**: v0.1.0 +**基于规范**: OpenTelemetry GenAI Semantic Conventions (最新版本) + + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/migration-plan.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/migration-plan.md new file mode 100644 index 00000000..16723d83 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/migration-plan.md @@ -0,0 +1,220 @@ +# Google ADK 插件迁移执行计划 + +## 项目概况 + +本文档描述如何将 Google ADK 插件从 ARMS 商业版本迁移到 LoongSuite 开源项目。 + +### 商业版本现状 +- **位置**:`aliyun-instrumentation-google-adk/` +- **命名空间**:`aliyun.instrumentation.google_adk` +- **架构**:基于 Google ADK Plugin 机制 +- **依赖特征**:依赖 ARMS SDK (`aliyun.sdk.extension.arms`) + +### 目标开源版本 +- **位置**:`instrumentation-genai/opentelemetry-instrumentation-google-adk/` +- **命名空间**:`opentelemetry.instrumentation.google_adk` +- **参考项目**:`opentelemetry-instrumentation-openai-v2` + +--- + +## 核心差异对照表 + +| 项目 | 商业版本 (ARMS) | 开源版本 (OTel) | +|------|----------------|-----------------| +| **命名空间** | `aliyun.instrumentation.google_adk` | `opentelemetry.instrumentation.google_adk` | +| **类名前缀** | `Aliyun*` | 标准OTel命名,无前缀 | +| **环境变量** | `ENABLE_GOOGLE_ADK_INSTRUMENTOR` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | +| **依赖项** | 依赖 ARMS SDK | 仅依赖标准 OTel SDK | +| **指标名称** | ARMS专有 + GenAI混合 (12个指标) | 标准 GenAI 语义规范 (2个指标) | +| **内容捕获** | ARMS SDK `process_content()` | 环境变量控制 | +| **包名** | `aliyun-instrumentation-google-adk` | `opentelemetry-instrumentation-google-adk` | + +> 📖 **详细差异分析**:请参阅 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 获取 Trace 和 Metrics 的完整对比分析。 + +--- + +## 详细迁移步骤 + +### 阶段一:项目结构创建(0.5天) + +创建目录结构: +``` +instrumentation-genai/opentelemetry-instrumentation-google-adk/ +├── src/ +│ └── opentelemetry/ +│ └── instrumentation/ +│ └── google_adk/ +│ ├── __init__.py +│ ├── package.py +│ ├── version.py +│ └── internal/ +│ ├── __init__.py +│ ├── _plugin.py +│ ├── _extractors.py +│ ├── _metrics.py +│ └── _utils.py +├── tests/ +├── pyproject.toml +├── README.md +├── LICENSE +└── CHANGELOG.md +``` + +### 阶段二:核心代码迁移(2天) + +> 📖 **重要**:迁移前请先阅读 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 了解详细差异。 + +#### 任务 2.1:迁移主入口 `__init__.py` +- 命名空间:`aliyun` → `opentelemetry` +- 类名:`AliyunGoogleAdkInstrumentor` → `GoogleAdkInstrumentor` +- 移除:`_ENABLE_GOOGLE_ADK_INSTRUMENTOR` 环境变量检查 +- 使用标准 OTel schema URL:`Schemas.V1_28_0.value` +- 移除 `_is_instrumentation_enabled()` 方法 + +#### 任务 2.2:迁移 `_plugin.py` +- 类名:`AliyunAdkObservabilityPlugin` → `GoogleAdkObservabilityPlugin` +- 移除:ARMS SDK 导入 +- 实现标准内容捕获机制(参考对比文档 1.3 节) +- 更新所有 `process_content()` 调用 +- 考虑使用 Event API 记录消息内容(推荐) + +#### 任务 2.3:迁移 `_extractors.py` +- 移除 ARMS SDK 导入 +- 使用本地 `_process_content()` 替换 +- 确保属性提取符合标准 GenAI 语义规范(参考对比文档 1.1 节) +- 关键修改: + - 移除 `gen_ai.model_name` 冗余属性 + - 修正 `finish_reason` 为 `finish_reasons` (数组) + - 移除冗余的 Tool 属性 + - 调整 Span 命名格式(参考对比文档 1.2 节) + +#### 任务 2.4:迁移 `_metrics.py` ⚠️ **最复杂部分** +- **完全重构**:参考 `openai-v2/instruments.py` 实现 +- 移除所有 ARMS 指标(12个 → 2个) +- 实现标准 OTel GenAI 指标: + - `gen_ai.client.operation.duration` (Histogram) + - `gen_ai.client.token.usage` (Histogram) +- 使用标准 GenAI 属性(参考对比文档 2.2 节) +- 移除 session/user 作为指标维度(避免高基数) +- 详细对比请参阅对比文档第 2 节 + +### 阶段三:测试迁移(1.5天) + +需要迁移的测试文件: +- ✅ `test_basic.py` +- ✅ `test_plugin.py` +- ✅ `test_extractors.py` +- ✅ `test_metrics.py`(需要大幅修改) +- ✅ `test_utils.py` +- ✅ `test_semantic_convention_compliance.py` +- ✅ `test_content_capture.py` +- ❌ `test_arms_compatibility.py`(移除) +- ✅ `test_trace_validation.py` + +### 阶段四:文档和配置(0.5天) + +创建完整的开源项目文档。 + +### 阶段五:验证和优化(1天) + +- 功能验证 +- 语义规范合规性检查 +- 代码清理 + +--- + +## 关键风险点 + +### 1. 指标系统重构 ⚠️ **高风险** + +**问题**:商业版本使用了双指标体系(ARMS + GenAI),共 12 个指标;开源版本只能用标准 GenAI 指标,仅 2 个。 + +**影响**: +- ❌ 失去:错误计数、慢调用计数、首包延迟等专有指标 +- ✅ 保留:操作耗时、Token 用量(通过标准 Histogram) +- ⚠️ 需确认:首包延迟是否有标准指标 + +**缓解措施**: +1. 参考 `openai-v2/instruments.py` 的标准实现 +2. 评估功能缺失的影响(大部分可通过 Histogram 聚合补偿) +3. 必要时考虑自定义扩展(如首包延迟) + +详细分析请参阅 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 第 2 节。 + +### 2. 内容捕获机制 + +**问题**:需要自己实现,基于环境变量控制,确保不泄露敏感信息。 + +**挑战**: +- ARMS SDK 的 `process_content()` 提供了自动截断和敏感信息过滤 +- 开源版本需要手动实现这些功能 + +**缓解措施**: +1. 实现 `_process_content()` 工具函数 +2. 支持 `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` 环境变量 +3. 支持 `OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH` 长度限制 +4. 考虑迁移到 Event API(OTel 推荐) + +详细实现请参阅 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 第 1.3 节。 + +### 3. Session/User 追踪 + +**问题**:需要确认这些是否符合标准 OTel 规范。 + +**待确认**: +- ❓ 标准 GenAI 规范是否定义了 `gen_ai.session.id` 和 `gen_ai.user.id`? +- ❓ 如果未定义,是否允许自定义扩展? +- ❓ 这些属性应该在 Trace 中还是 Metrics 中? + +**建议**: +1. 查阅最新 OTel GenAI 语义规范 v1.37.0 +2. Session/User 信息仅在 Trace 中记录,不作为 Metrics 维度(避免高基数) +3. 如果标准未定义,使用自定义命名空间(如 `google_adk.session.id`) + +详细讨论请参阅 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 第 4.1 节。 + +--- + +## 时间估算 + +| 阶段 | 预计时间 | +|------|----------| +| 阶段一:项目结构创建 | 0.5天 | +| 阶段二:核心代码迁移 | 2天 | +| 阶段三:测试迁移 | 1.5天 | +| 阶段四:文档和配置 | 0.5天 | +| 阶段五:验证和优化 | 1天 | +| **总计** | **5.5天** | + +--- + +## 迁移检查清单 + +### 代码层面 +- [ ] 所有文件命名空间从 `aliyun` 改为 `opentelemetry` +- [ ] 所有类名移除 `Aliyun` 前缀 +- [ ] 移除所有 ARMS SDK 依赖和导入 +- [ ] 实现标准内容捕获机制 +- [ ] 指标完全符合 GenAI 规范 +- [ ] 移除所有 ARMS 专有环境变量 +- [ ] 移除所有 ARMS 专有属性和标签 + +### 测试层面 +- [ ] 所有测试导入路径更新 +- [ ] 移除 ARMS 专有测试 +- [ ] 更新指标验证逻辑 +- [ ] 更新环境变量测试 +- [ ] 所有测试通过 + +### 文档层面 +- [ ] README.md 完整 +- [ ] LICENSE 正确 +- [ ] CHANGELOG.md 创建 +- [ ] pyproject.toml 正确配置 +- [ ] 注释英文化 + +### 规范层面 +- [ ] Span 属性符合 GenAI 规范 +- [ ] Metric 名称符合 GenAI 规范 +- [ ] Span kind 正确映射 +- [ ] Schema URL 正确 \ No newline at end of file diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/trace-metrics-comparison.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/trace-metrics-comparison.md new file mode 100644 index 00000000..8e55279c --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/trace-metrics-comparison.md @@ -0,0 +1,674 @@ +# Google ADK 插件 Trace & Metrics 差异对比分析 + +本文档详细对比商业版本(ARMS)和开源版本(OTel)在 Trace 和 Metrics 实现上的差异。 + +**基于 OTel GenAI Semantic Conventions(最新版本)** + +--- + +## 一、Trace 差异分析 + +### 1.1 Span 属性命名规范对比 + +| 属性类别 | 商业版本 (ARMS) | 开源版本 (OTel 最新) | 一致性 | 备注 | +|---------|----------------|-----------------|--------|------| +| **核心属性** | +| Operation Name | `gen_ai.operation.name` | `gen_ai.operation.name` | ✅ 一致 | chat/invoke_agent/execute_tool | +| Provider | `gen_ai.system` | `gen_ai.provider.name` | ❌ **名称变更** | **必须改为 provider.name** | +| Framework | `gen_ai.framework` | 无 | ❌ 非标准 | 需要去除 | +| **LLM 请求属性** | +| Model Name | `gen_ai.model_name` | 无 | ❌ **冗余,需移除** | 只保留 request.model | +| | `gen_ai.request.model` | `gen_ai.request.model` | ✅ 一致 | | +| Max Tokens | `gen_ai.request.max_tokens` | `gen_ai.request.max_tokens` | ✅ 一致 | | +| Temperature | `gen_ai.request.temperature` | `gen_ai.request.temperature` | ✅ 一致 | | +| Top P | `gen_ai.request.top_p` | `gen_ai.request.top_p` | ✅ 一致 | | +| Top K | `gen_ai.request.top_k` | `gen_ai.request.top_k` | ✅ 一致 | | +| Stream | ❌ `gen_ai.request.is_stream` | 无此属性 | ❌ 非标准 | 需要移除 | +| **LLM 响应属性** | +| Response Model | `gen_ai.response.model` | `gen_ai.response.model` | ✅ 一致 | | +| Finish Reason | `gen_ai.response.finish_reason` | `gen_ai.response.finish_reasons` | ❌ **单复数差异** | **必须改为复数数组** | +| Input Tokens | `gen_ai.usage.input_tokens` | `gen_ai.usage.input_tokens` | ✅ 一致 | | +| Output Tokens | `gen_ai.usage.output_tokens` | `gen_ai.usage.output_tokens` | ✅ 一致 | | +| Total Tokens | ❌ `gen_ai.usage.total_tokens` | 无 | ❌ 非标准 | 需要移除 | +| **消息内容** | +| Input Messages | `gen_ai.input.messages` | `gen_ai.input.messages` | ✅ **一致** | Opt-In 属性,需遵循 JSON Schema | +| Output Messages | `gen_ai.output.messages` | `gen_ai.output.messages` | ✅ **一致** | Opt-In 属性,需遵循 JSON Schema | +| System Instructions | `gen_ai.system_instructions` | `gen_ai.system_instructions` | ✅ 一致 | Opt-In 属性 | +| Tool Definitions | `gen_ai.tool.definitions` | `gen_ai.tool.definitions` | ✅ 一致 | Opt-In 属性 | +| Message Count | `gen_ai.input.message_count` | 无 | ❌ 非标准,移除 | 可从 messages 数组获取 | +| | `gen_ai.output.message_count` | 无 | ❌ 非标准,移除 | | +| **Session 追踪** | +| Session/Conversation ID | `gen_ai.session.id` | `gen_ai.conversation.id` | ⚠️ **名称不同** | **改为 conversation.id** | +| User ID | ❌ `gen_ai.user.id` | 无标准属性 | ❌ 非标准 | 考虑使用 `enduser.id` (标准) | +| **Agent 属性(invoke_agent spans)** | +| Agent Name | `agent.name` | `gen_ai.agent.name` | ⚠️ 缺少前缀 | 应改为 `gen_ai.agent.name` | +| Agent ID | 无 | `gen_ai.agent.id` | ❌ 缺失 | 尽可能采集,如果无法获取到(如框架中没有定义)则不采集 | +| Agent Description | `agent.description` | `gen_ai.agent.description` | ⚠️ 缺少前缀 | 应改为 `gen_ai.agent.description` | +| Data Source ID | 无 | `gen_ai.data_source.id` | ❌ 缺失 | RAG 场景需要,应尽可能采集 | +| **Tool 属性(execute_tool spans)** | +| Tool Name | `tool.name` / `gen_ai.tool.name` | `gen_ai.tool.name` | ⚠️ 缺少前缀 | 商业版有 `tool.name`,应统一为 `gen_ai.tool.name` | +| Tool Description | `tool.description` / `gen_ai.tool.description` | `gen_ai.tool.description` | ⚠️ 缺少前缀 | 同上,应统一为 `gen_ai.tool.description` | +| Tool Parameters | `tool.parameters` | `gen_ai.tool.call.arguments` | ❌ **属性名错误** | 应改为 `gen_ai.tool.call.arguments` | +| Tool Call ID | 无 | `gen_ai.tool.call.id` | ❌ 缺失 | 应尽可能采集,如果无法获取到(如框架中没有定义)则不采集 | +| Tool Type | 无 | `gen_ai.tool.type` | ❌ 缺失 | 默认为 function,应尽可能采集,如果无法获取到(如框架中没有定义)则不采集 | +| Tool Result | 无 | `gen_ai.tool.call.result` | ❌ 缺失 | 应尽可能采集,如果无法获取到(如框架中没有定义)则不采集 | +| **错误属性** | +| Error Type | `error.type` | `error.type` | ✅ 一致 | | +| Error Message | `error.message` | 无(非标准) | ⚠️ | OTel 推荐使用 span status | +| **ADK 框架专有属性** | +| App Name | `runner.app_name` | 无 | ❌ 非标准 | 考虑作为自定义扩展保留 | +| Invocation ID | `runner.invocation_id` | 无 | ❌ 非标准 | 考虑作为自定义扩展保留 | + +### 1.2 Span 命名规范对比 + +| Span 类型 | 商业版本 (ARMS) | OTel 标准命名 | 一致性 | 说明 | +|----------|----------------|---------------|--------|------| +| **LLM (Inference)** | `chat {model}` | `{operation_name} {request.model}` | ✅ 基本一致 | 如 `chat gpt-4` | +| **Agent (Invoke)** | `invoke_agent {agent_name}` | `invoke_agent {agent.name}` | ✅ 一致 | 如 `invoke_agent Math Tutor` | +| | | 或 `invoke_agent` (无名称时) | | | +| **Agent (Create)** | 无 | `create_agent {agent.name}` | ❌ 缺失 | 创建 agent 场景 | +| **Tool** | `execute_tool {tool_name}` | `execute_tool {tool.name}` | ✅ 一致 | 如 `execute_tool get_weather` | +| **Runner** | `invoke_agent {app_name}` | 同 Agent Invoke | ⚠️ 需调整 | Runner 视为顶级 Agent | + +**OTel 标准规范**: +- **LLM spans**: `{gen_ai.operation.name} {gen_ai.request.model}` + - 示例:`chat gpt-4`, `generate_content gemini-pro` +- **Agent invoke spans**: `invoke_agent {gen_ai.agent.name}` 或 `invoke_agent`(name 不可用时) +- **Agent create spans**: `create_agent {gen_ai.agent.name}` +- **Tool spans**: `execute_tool {gen_ai.tool.name}` + - 示例:`execute_tool get_weather`, `execute_tool search` + +### 1.3 内容捕获机制对比 + +| 特性 | 商业版本 (ARMS) | 开源版本 (OTel) | +|-----|----------------|-----------------| +| **实现方式** | ARMS SDK `process_content()` | 自实现 + 环境变量 | +| **控制变量** | `ENABLE_GOOGLE_ADK_INSTRUMENTOR` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | +| **长度限制** | ARMS SDK 内置 | `OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH` | +| **截断标记** | ARMS 自动处理 | 需自实现 `[TRUNCATED]` | +| **敏感信息** | ARMS SDK 处理 | 需自己实现过滤 | +| **存储位置** | Span attributes | Events (推荐) 或 Attributes | + +**商业版本实现**: +```python +from aliyun.sdk.extension.arms.utils.capture_content import process_content + +# 自动处理长度限制和敏感信息过滤 +content = process_content(raw_content) +span.set_attribute("gen_ai.input.messages", content) +``` + +**开源版本需要实现**: +```python +import os + +def _should_capture_content() -> bool: + return os.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "false").lower() == "true" + +def _get_max_length() -> Optional[int]: + limit = os.getenv("OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH") + return int(limit) if limit else None + +def _process_content(content: str) -> str: + if not _should_capture_content(): + return "" + + max_length = _get_max_length() + if max_length and len(content) > max_length: + return content[:max_length] + " [TRUNCATED]" + + return content + +# 推荐使用 Event API 而非 Attribute +event_logger.emit(Event( + name="gen_ai.content.prompt", + attributes={"content": _process_content(content)} +)) +``` + +### 1.4 Span Kind 和 Operation Name 对比 + +| ADK 组件 | 商业版本 | OTel 标准 | OTel SpanKind | 说明 | +|---------|---------|----------|---------------|------| +| **LLM 调用** | ❌ 使用 `gen_ai.span.kind` | ✅ `gen_ai.operation.name=chat` | `CLIENT` | **不使用 span.kind 属性** | +| **Runner** | ❌ `gen_ai.span.kind=AGENT` | ✅ `operation.name=invoke_agent` | `CLIENT` | **必须改用 operation.name** | +| **BaseAgent** | ❌ `gen_ai.span.kind=AGENT` | ✅ `operation.name=invoke_agent` | `CLIENT` | 同上 | +| **Tool** | ❌ `gen_ai.span.kind=TOOL` | ✅ `operation.name=execute_tool` | `INTERNAL` | 同上,规范建议 INTERNAL | + +**重要变更**: +- ❌ **`gen_ai.span.kind` 不是标准属性**,需要完全移除 +- ✅ 使用 `gen_ai.operation.name` 区分操作类型: + - `chat` - LLM 聊天 + - `generate_content` - 多模态内容生成 + - `invoke_agent` - 调用 Agent + - `create_agent` - 创建 Agent + - `execute_tool` - 执行工具 + - `embeddings` - 向量嵌入 + - `text_completion` - 文本补全(Legacy) + +- ✅ OTel `SpanKind` 的选择: + - `CLIENT` - 调用外部服务(LLM API, 远程 Agent)**推荐默认** + - `INTERNAL` - 本地处理(本地 Agent, 本地 Tool) + +**这是最大的变更点之一!** + +### 1.5 Tool 属性详细说明(重要补充) + +根据 OTel GenAI 规范的 "Execute tool span" 部分,标准定义了完整的 Tool 属性集: + +| 属性名称 | 类型 | 要求级别 | 描述 | 示例 | +|---------|------|---------|------|------| +| `gen_ai.operation.name` | string | **Required** | 必须为 `"execute_tool"` | `execute_tool` | +| `gen_ai.tool.name` | string | **Recommended** | 工具名称 | `get_weather`, `search` | +| `gen_ai.tool.description` | string | Recommended (if available) | 工具描述 | `Get weather information` | +| `gen_ai.tool.call.id` | string | Recommended (if available) | 工具调用唯一标识 | `call_mszuSIzqtI65i1wAUOE8w5H4` | +| `gen_ai.tool.type` | string | Recommended (if available) | 工具类型 | `function`, `extension`, `datastore` | +| `gen_ai.tool.call.arguments` | any | **Opt-In** | 传递给工具的参数 | `{"location": "Paris", "date": "2025-10-01"}` | +| `gen_ai.tool.call.result` | any | **Opt-In** | 工具返回的结果 | `{"temperature": 75, "conditions": "sunny"}` | +| `error.type` | string | Conditionally Required | 错误类型(如果有错误) | `timeout` | + +**商业版本 vs 开源版本对照**: + +```python +# ❌ 商业版本(错误的实现) +span.set_attribute("tool.name", "get_weather") # 缺少 gen_ai 前缀 +span.set_attribute("tool.description", "Get weather") # 缺少 gen_ai 前缀 +span.set_attribute("tool.parameters", json.dumps({...})) # 错误的属性名 +# 缺失: tool.call.id, tool.type, tool.call.result + +# ✅ 开源版本(正确的实现) +span.set_attribute("gen_ai.operation.name", "execute_tool") # Required +span.set_attribute("gen_ai.tool.name", "get_weather") # Recommended +span.set_attribute("gen_ai.tool.description", "Get weather") # Recommended +span.set_attribute("gen_ai.tool.call.id", "call_123") # Recommended +span.set_attribute("gen_ai.tool.type", "function") # Recommended +span.set_attribute("gen_ai.tool.call.arguments", {...}) # Opt-In (结构化) +span.set_attribute("gen_ai.tool.call.result", {...}) # Opt-In (结构化) +``` + +**关键差异**: +1. ✅ **前缀必须**: 所有属性都需要 `gen_ai.` 前缀 +2. ✅ **参数和结果**: 使用 `tool.call.arguments` 和 `tool.call.result`(而非 `tool.parameters`) +3. ✅ **新增属性**: `tool.call.id` 和 `tool.type` 是新增的标准属性 +4. ✅ **Span name**: 应为 `execute_tool {tool.name}` +5. ✅ **Span kind**: 应为 `INTERNAL`(不是 `CLIENT`) + +--- + +## 二、Metrics 差异分析 + +### 2.1 指标名称和类型对比 + +#### 标准 OTel GenAI Client Metrics(最新规范) + +| 指标名称 | 类型 | 单位 | 描述 | 必需属性 | 推荐属性 | +|---------|------|------|------|---------|---------| +| `gen_ai.client.operation.duration` | Histogram | `s` (秒) | 客户端操作耗时 | `gen_ai.operation.name`
`gen_ai.provider.name` | `gen_ai.request.model`
`gen_ai.response.model`
`server.address`
`server.port`
`error.type` (错误时) | +| `gen_ai.client.token.usage` | Histogram | `{token}` | Token 使用量 | 同上
`gen_ai.token.type` | 同上 | + +**标准规范要点**: +- ✅ **仅 2 个客户端指标**,使用 Histogram 类型 +- ✅ `gen_ai.provider.name` 是**必需属性**(不是 `system`) +- ✅ `gen_ai.token.type` 值为 `input` 或 `output` +- ✅ `error.type` 仅在错误时设置 +- ❌ **没有**单独的错误计数器、慢调用计数器等 + +#### 商业版本 ARMS 指标(当前实现)- **需要完全移除** + +| 指标名称 | 类型 | 状态 | 迁移方案 | +|---------|------|------|---------| +| **ARMS 专有指标** | | | | +| `calls_count` | Counter | ❌ **移除** | 用 `operation.duration` Histogram 替代 | +| `calls_duration_seconds` | Histogram | ❌ **移除** | 用标准 `operation.duration` 替代 | +| `call_error_count` | Counter | ❌ **移除** | 通过 `operation.duration` + `error.type` 维度查询 | +| `llm_usage_tokens` | Counter | ❌ **移除** | 用标准 `token.usage` Histogram 替代 | +| `llm_first_token_seconds` | Histogram | ⚠️ **可选保留** | 标准无此指标,见下方说明 | +| **自定义 GenAI 指标** | | | | +| `genai_calls_count` | Counter | ❌ **移除** | 同上 | +| `genai_calls_duration_seconds` | Histogram | ❌ **移除** | 同上 | +| `genai_calls_error_count` | Counter | ❌ **移除** | 同上 | +| `genai_calls_slow_count` | Counter | ❌ **移除** | 通过 Histogram 百分位聚合获得 | +| `genai_llm_first_token_seconds` | Histogram | ⚠️ **可选保留** | 同上 | +| `genai_llm_usage_tokens` | Counter | ❌ **移除** | 同上 | +| `genai_avg_first_token_seconds` | Histogram | ❌ **移除** | 由后端聚合计算 | + +**关键变化**: +- ❌ **移除双指标体系**:12 个指标 → 2 个标准指标 +- ❌ **移除所有 Counter**:改用 Histogram,由后端聚合 +- ❌ **移除显式错误/慢调用计数**:通过 Histogram + 维度查询获得 +- ⚠️ **首包延迟处理**:需要决策(见下方) + +### 2.2 指标维度(Labels/Attributes)对比 + +#### 标准 OTel GenAI Metrics 维度(必须遵循) + +```python +# operation.duration 和 token.usage 的必需属性 +{ + "gen_ai.operation.name": "chat", # Required: chat/invoke_agent/execute_tool 等 + "gen_ai.provider.name": "openai", # Required: 提供商标识 +} + +# 推荐属性(根据可用性添加) +{ + "gen_ai.request.model": "gpt-4", # Recommended: 请求的模型 + "gen_ai.response.model": "gpt-4-0613", # Recommended: 实际响应的模型 + "server.address": "api.openai.com", # Recommended: 服务器地址 + "server.port": 443, # Recommended (如果有 address) + "error.type": "TimeoutError", # Conditionally Required: 仅错误时 +} + +# token.usage 专有属性 +{ + "gen_ai.token.type": "input", # Required: "input" 或 "output" +} +``` + +#### 商业版本 ARMS Metrics 维度(**需要完全移除**) + +```python +{ + # ❌ ARMS 专有维度 - 全部移除 + "callType": "gen_ai", # 移除 + "callKind": "custom_entry", # 移除 + "rpcType": 2100, # 移除 + "rpc": "chat gpt-4", # 移除 + + # ❌ 错误的属性名 - 需要改名 + "modelName": "gpt-4", # → gen_ai.request.model + "spanKind": "LLM", # → gen_ai.operation.name + "usageType": "input", # → gen_ai.token.type + + # ❌ 不应出现在指标中的高基数属性 + "session_id": "...", # 移除(仅用于 trace) + "user_id": "...", # 移除(仅用于 trace) +} +``` + +**关键差异总结**: +1. ❌ **必须移除**所有 ARMS 专有维度:`callType`, `callKind`, `rpcType`, `rpc` +2. ❌ **必须改名**:`modelName` → `gen_ai.request.model`, `usageType` → `gen_ai.token.type` +3. ❌ **必须移除** `spanKind` 维度,改用 `gen_ai.operation.name` +4. ❌ **必须移除**高基数属性:`session_id`, `user_id`(这些仅用于 trace) +5. ✅ **必须添加** `gen_ai.provider.name`(新的必需属性) + +### 2.3 指标记录逻辑对比 + +#### 标准 OTel 实现(openai-v2) + +```python +# 1. 记录操作耗时 +instruments.operation_duration_histogram.record( + duration, + attributes={ + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "gpt-4", + "gen_ai.response.model": "gpt-4-0613", + "gen_ai.system": "openai", + "error.type": error_type, # 仅在错误时 + } +) + +# 2. 记录 Token 用量(输入) +instruments.token_usage_histogram.record( + input_tokens, + attributes={ + # ... 同上 + "gen_ai.token.type": "input", + } +) + +# 3. 记录 Token 用量(输出) +instruments.token_usage_histogram.record( + output_tokens, + attributes={ + # ... 同上 + "gen_ai.token.type": "output", + } +) +``` + +**特点**: +- ✅ **简洁**:只记录 2 个指标,多次调用 +- ✅ **标准化**:完全符合 OTel 语义规范 +- ✅ **通过属性区分**:用 `error.type` 区分成功/失败,而非单独的错误计数器 + +#### 商业版本 ARMS 实现 + +```python +# 1. ARMS 指标(主要,用于控制台) +self.calls_count.add(1, attributes=arms_labels) +self.calls_duration_seconds.record(duration, attributes=arms_labels) +if is_error: + self.call_error_count.add(1, attributes=arms_labels) + +# 2. Token 用量(ARMS 格式) +if prompt_tokens > 0: + self.llm_usage_tokens.add(prompt_tokens, attributes={ + **arms_labels, + "usageType": "input" + }) +if completion_tokens > 0: + self.llm_usage_tokens.add(completion_tokens, attributes={ + **arms_labels, + "usageType": "output" + }) + +# 3. 首包延迟 +if first_token_time: + self.llm_first_token_seconds.record(first_token_time, attributes=arms_labels) + self.genai_avg_first_token_seconds.record(first_token_time, ...) + +# 4. GenAI 兼容指标(辅助) +self.genai_calls_count.add(1, genai_labels) +self.genai_calls_duration.record(duration, genai_labels) +if is_error: + self.genai_calls_error_count.add(1, genai_labels) +if is_slow: + self.genai_calls_slow_count.add(1, genai_labels) +# ... 更多 +``` + +**特点**: +- ❌ **复杂**:双指标体系,每次调用记录多个指标 +- ❌ **冗余**:相同信息记录两次(ARMS + GenAI) +- ⚠️ **慢调用**:自定义 `genai_calls_slow_count`,标准 OTel 应通过 Histogram 聚合 +- ⚠️ **首包延迟**:两个指标,标准可能只需一个 + +### 2.4 首包延迟(Time to First Token)处理 + +#### 标准 OTel 规范 + +查阅最新的 OTel GenAI Metrics 规范,发现: +- ❌ **客户端指标中没有首包延迟** +- ✅ **服务端指标有** `gen_ai.server.time_to_first_token` (Histogram) + - 用于模型服务器端的监控 + - 客户端插件通常不实现服务端指标 + +#### 商业版本实现 + +```python +# 当前实现:2 个首包延迟指标 +self.llm_first_token_seconds.record(first_token_time, ...) # ARMS 指标 +self.genai_llm_first_token_seconds.record(first_token_time, ...) # GenAI 指标 +self.genai_avg_first_token_seconds.record(first_token_time, ...) # 平均指标 +``` + +#### 迁移决策 + +**选项 1:移除首包延迟指标(推荐)** +- ✅ 符合标准 OTel 客户端规范 +- ✅ 减少指标数量 +- ❌ 失去首包延迟可见性 + +**选项 2:保留为自定义扩展** +```python +# 自定义指标(非标准) +self.gen_ai_client_time_to_first_token = meter.create_histogram( + name="gen_ai.client.time_to_first_token", # 自定义名称 + description="Time to first token for streaming responses", + unit="s" +) +``` +- ✅ 保留首包延迟可见性 +- ⚠️ 非标准,需要明确文档说明 +- ⚠️ 需要评估是否真正需要 + +**建议**: +- 对于开源版本,推荐**选项 1**(移除) +- Google ADK 目前没有提供原生的首包延迟数据 +- 如果确实需要,可以在 span 中记录为事件或属性 + +### 2.5 Agent/Tool 指标处理 + +#### 商业版本问题 + +```python +# ❌ 错误的实现 +record_agent_call( + span_kind="AGENT", # 使用非标准的 span_kind + agent_name="my_agent", + session_id="...", # 高基数属性 + user_id="..." # 高基数属性 +) +``` + +#### 标准 OTel 实现 + +```python +# ✅ 正确的实现 +instruments.operation_duration_histogram.record( + duration, + attributes={ + "gen_ai.operation.name": "invoke_agent", + "gen_ai.provider.name": "google_adk", + "gen_ai.request.model": agent_name, # Agent 名称作为 model + # 或者 + # "gen_ai.agent.name": agent_name, # 如果适用 + } +) + +# Token 使用量(如果有) +instruments.token_usage_histogram.record( + token_count, + attributes={ + "gen_ai.operation.name": "invoke_agent", + "gen_ai.provider.name": "google_adk", + "gen_ai.token.type": "input", # 或 "output" + "gen_ai.request.model": agent_name, + } +) +``` + +**关键点**: +1. ✅ 统一使用 2 个标准指标 +2. ✅ 通过 `gen_ai.operation.name` 区分操作类型 +3. ❌ 完全移除 session_id/user_id(仅在 trace 中) +4. ✅ Agent/Tool 名称可以放在 `gen_ai.request.model` 或 `gen_ai.agent.name` + +--- + +## 三、迁移行动计划 + +### 3.1 Trace 迁移要点(基于最新规范) + +| 任务 | 优先级 | 复杂度 | 说明 | +|------|--------|--------|------| +| **🔥 核心属性变更** | +| ❌ `gen_ai.system` → ✅ `gen_ai.provider.name` | 🔴 **最高** | 🟢 低 | **所有地方都要改** | +| ❌ 移除 `gen_ai.span.kind` | 🔴 **最高** | 🟡 中 | **完全移除,改用 operation.name** | +| ❌ 移除 `gen_ai.framework` | 🔴 高 | 🟢 低 | 非标准属性 | +| **属性名称标准化** | +| 移除 `gen_ai.model_name` 冗余 | 🔴 高 | 🟢 低 | 只保留 `gen_ai.request.model` | +| 修正 `finish_reason` → `finish_reasons` | 🔴 高 | 🟢 低 | 必须改为复数数组 | +| `session.id` → `conversation.id` | 🔴 高 | 🟢 低 | 标准属性名称 | +| 考虑 `user.id` → `enduser.id` | 🟡 中 | 🟢 低 | 使用标准用户ID属性 | +| **Agent 属性标准化** | +| `agent.name` → `gen_ai.agent.name` | 🔴 高 | 🟢 低 | 添加 gen_ai 前缀 | +| `agent.description` → `gen_ai.agent.description` | 🔴 高 | 🟢 低 | 同上 | +| 添加 `gen_ai.agent.id` | 🟡 中 | 🟢 低 | 新的标准属性 | +| **Tool 属性标准化** | +| `tool.name` → `gen_ai.tool.name` | 🔴 高 | 🟢 低 | 添加 gen_ai 前缀 | +| `tool.description` → `gen_ai.tool.description` | 🔴 高 | 🟢 低 | 同上 | +| `tool.parameters` → `gen_ai.tool.call.arguments` | 🔴 高 | 🟢 低 | 属性名变更 (Opt-In) | +| 添加 `gen_ai.tool.call.id` | 🟡 中 | 🟢 低 | 新的 Recommended 属性 | +| 添加 `gen_ai.tool.type` | 🟡 中 | 🟢 低 | 新的 Recommended 属性 | +| 添加 `gen_ai.tool.call.result` | 🟡 中 | 🟢 低 | 新的 Opt-In 属性 | +| **内容捕获机制** | +| 实现 `_process_content()` | 🔴 高 | 🟡 中 | 替换 ARMS SDK | +| 遵循 JSON Schema | 🔴 高 | 🟡 中 | input/output messages 格式 | +| **ADK 专有属性处理** | +| `runner.app_name` / `invocation_id` | 🟡 中 | 🟢 低 | 考虑保留为自定义扩展 | + +### 3.2 Metrics 迁移要点(最新规范) + +| 任务 | 优先级 | 复杂度 | 说明 | +|------|--------|--------|------| +| **🔥 完全重构指标系统** | +| ❌ 移除所有 ARMS 指标(5个) | 🔴 **最高** | 🟡 中 | 移除 `calls_count`, `llm_usage_tokens` 等 | +| ❌ 移除所有自定义 GenAI 指标(7个) | 🔴 **最高** | 🟡 中 | 移除 `genai_calls_count` 等 | +| ✅ 实现标准 2 个指标 | 🔴 **最高** | 🟠 高 | 参考 `openai-v2/instruments.py` | +| **✅ 标准指标实现** | +| `gen_ai.client.operation.duration` | 🔴 **最高** | 🟠 高 | Histogram, 单位=秒 | +| `gen_ai.client.token.usage` | 🔴 **最高** | 🟠 高 | Histogram, 单位=token | +| **🔥 维度完全重构** | +| ❌ 移除所有 ARMS 维度 | 🔴 **最高** | 🟡 中 | `callType`, `callKind`, `rpcType`, `rpc` | +| ❌ `spanKind` → ✅ `operation.name` | 🔴 **最高** | 🟡 中 | 概念完全不同 | +| ❌ `modelName` → ✅ `request.model` | 🔴 **最高** | 🟢 低 | 属性名变更 | +| ❌ `usageType` → ✅ `token.type` | 🔴 **最高** | 🟢 低 | 属性名变更 | +| ✅ 添加 `provider.name`(必需) | 🔴 **最高** | 🟢 低 | 新的必需属性 | +| ❌ 移除 `session_id`/`user_id` | 🔴 高 | 🟢 低 | 高基数,仅用于 trace | +| **功能调整** | +| 移除错误计数器 | 🔴 高 | 🟢 低 | 用 `error.type` 维度查询 | +| 移除慢调用计数器 | 🔴 高 | 🟢 低 | 通过 Histogram 百分位聚合 | +| 首包延迟处理 | 🟡 中 | 🟡 中 | 选项1:移除 或 选项2:自定义 | + +### 3.3 测试迁移要点 + +| 测试类型 | 商业版本 | 开源版本 | 迁移动作 | +|---------|---------|----------|---------| +| **保留并修改** | +| 基础功能测试 | `test_basic.py` | ✅ 保留 | 更新导入和类名 | +| Plugin 测试 | `test_plugin.py` | ✅ 保留 | 更新环境变量测试 | +| Extractor 测试 | `test_extractors.py` | ✅ 保留 | 验证属性名称 | +| 工具函数测试 | `test_utils.py` | ✅ 保留 | 测试新的内容捕获 | +| Trace 验证 | `test_trace_validation.py` | ✅ 保留 | 更新属性检查 | +| 语义规范测试 | `test_semantic_convention_compliance.py` | ✅ 保留 | 更新为 OTel 规范 | +| **大幅修改** | +| 指标测试 | `test_metrics.py` | ✅ 保留 | **完全重写** | +| 内容捕获测试 | `test_content_capture.py` | ✅ 保留 | 更新环境变量 | +| **移除** | +| ARMS 兼容测试 | `test_arms_compatibility.py` | ❌ 移除 | ARMS 专有 | +| Session/User 测试 | `test_session_user_tracking.py` | ⚠️ 可选 | 如果标准支持则保留 | + +--- + +## 四、关键决策点(已基于最新规范确认) + +### 4.1 已确认的标准规范(基于最新版本) + +1. **✅ Session 追踪** + - ✅ 标准属性:`gen_ai.conversation.id` + - ✅ 用途:存储和关联对话中的消息 + - ✅ 仅用于 trace,不用于 metrics + +2. **⚠️ User 追踪** + - ❌ `gen_ai.user.id` 不是标准属性 + - ✅ 建议使用:`enduser.id` (标准 OTel 属性) + - ✅ 仅用于 trace,不用于 metrics + +3. **✅ Agent/Tool Operation Name** + - ✅ Agent invoke: `gen_ai.operation.name = "invoke_agent"` + - ✅ Agent create: `gen_ai.operation.name = "create_agent"` + - ✅ Tool execute: `gen_ai.operation.name = "execute_tool"` + - ✅ LLM chat: `gen_ai.operation.name = "chat"` + +4. **❌ Span Kind 属性不存在** + - ❌ `gen_ai.span.kind` 不是标准属性 + - ✅ 使用 `gen_ai.operation.name` 区分类型 + - ✅ 使用 OTel `SpanKind` (CLIENT/INTERNAL) + +5. **✅ Provider Name(重要变更)** + - ❌ 旧属性:`gen_ai.system` + - ✅ 新属性:`gen_ai.provider.name` + - ✅ 这是必需属性 + +6. **⚠️ 首包延迟(Time to First Token)** + - ❌ 客户端规范中没有此指标 + - ✅ 服务端有 `gen_ai.server.time_to_first_token` + - 📝 **决策**:开源版本建议移除,或作为自定义扩展 + +### 4.2 可选的自定义扩展 + +如果标准规范未覆盖以下功能,考虑自定义扩展: + +1. **首包延迟指标** (如果标准未定义) + ```python + gen_ai.client.time_to_first_token (Histogram) + ``` + +2. **ADK 专有属性** (如果确实有价值) + ```python + google_adk.runner.app_name + google_adk.runner.invocation_id + ``` + +3. **Session 追踪** (如果标准未定义) + ```python + session.id + user.id + ``` + +**原则**: +- ✅ 优先使用标准规范 +- ✅ 必要时可以扩展,但需明确标注为非标准 +- ❌ 避免与标准规范冲突 + +--- + +## 五、总结 + +### 5.1 主要差异总结(最新规范对比) + +| 维度 | 商业版本特点 | 开源版本目标 | 迁移难度 | 关键变更 | +|------|------------|------------|---------|---------| +| **Trace 核心** | ❌ 使用 `gen_ai.system`
❌ 使用 `gen_ai.span.kind` | ✅ 使用 `gen_ai.provider.name`
✅ 使用 `gen_ai.operation.name` | 🟠 **高** | **概念完全变更** | +| **Trace 属性** | 部分冗余,ARMS 专有 | 完全符合最新 OTel 标准 | 🟡 中等 | 多处属性名变更 | +| **Metrics** | 12 个指标,双体系 | 2 个标准指标 | 🔴 **很高** | **完全重构** | +| **Metrics 维度** | ARMS 专有维度多 | 标准 GenAI 属性 | 🔴 **很高** | **所有维度都要改** | +| **内容捕获** | ARMS SDK 自动 | 遵循 JSON Schema | 🟡 中等 | 需自实现 | +| **测试** | ARMS 专有测试多 | 标准 OTel 测试 | 🟡 中等 | 指标测试需重写 | + +**最关键的 3 个变更**: +1. 🔥 `gen_ai.span.kind` → `gen_ai.operation.name`(概念变更) +2. 🔥 `gen_ai.system` → `gen_ai.provider.name`(属性改名) +3. 🔥 12 个指标 → 2 个指标,所有维度重构(完全重构) + +### 5.2 迁移风险评估 + +| 风险点 | 严重程度 | 缓解措施 | +|--------|---------|---------| +| **Metrics 完全重构** | 🔴 高 | 参考 openai-v2 实现,分步验证 | +| **标准规范不明确** | 🟡 中 | 查阅最新规范,必要时提问社区 | +| **功能缺失** | 🟡 中 | 评估是否真正需要,考虑自定义扩展 | +| **测试覆盖不足** | 🟡 中 | 完善语义规范合规性测试 | + +### 5.3 迁移工作量评估(基于最新规范) + +| 阶段 | 工作量(人日) | 复杂度 | 说明 | +|------|--------------|--------|------| +| **Phase 1: Trace 核心变更** | 2-3 | 🟠 高 | `gen_ai.system` → `provider.name`
`span.kind` → `operation.name` | +| **Phase 2: Trace 属性标准化** | 2-3 | 🟡 中 | Agent/Tool 属性、session/user 等 | +| **Phase 3: 内容捕获机制** | 2-3 | 🟡 中 | 实现 `_process_content()`
JSON Schema 遵循 | +| **Phase 4: Metrics 完全重构** | 5-7 | 🔴 很高 | 移除 12 个指标
实现 2 个标准指标
重构所有维度 | +| **Phase 5: 测试重写** | 4-6 | 🟠 高 | Metrics 测试完全重写
Trace 测试更新 | +| **Phase 6: 文档和示例** | 1-2 | 🟢 低 | README、迁移指南 | +| **总计** | **16-24 人日** | | 约 **3.5-5 周** | + +**关键里程碑**: +- Week 1: Trace 核心变更完成 +- Week 2-3: Metrics 完全重构 +- Week 4: 测试和文档 +- Week 5: 验证和优化(可选) + +**最高风险阶段**:Phase 4 (Metrics 重构) + +### 5.4 预期收益 + +1. ✅ **标准化**:完全符合 OTel GenAI 语义规范(最新版本) +2. ✅ **简化**:指标从 12 个减少到 2 个,大幅降低维护成本 +3. ✅ **可移植**:可贡献到 OTel 官方仓库 +4. ✅ **兼容性**:与其他 OTel GenAI 插件(openai-v2 等)完全一致 +5. ✅ **社区支持**:获得 OTel 社区的长期支持和演进 +6. ✅ **正确性**:基于最新规范,避免未来需要再次迁移 + +--- + +**最后更新**:2025-10-21 +**基于规范**:OTel GenAI Semantic Conventions (最新版本) +**参考文档**: +- `semantic-convention-genai/gen-ai-spans.md` +- `semantic-convention-genai/gen-ai-metrics.md` +- `semantic-convention-genai/gen-ai-agent-spans.md` +- `semantic-convention-genai/gen-ai-events.md` + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app.py new file mode 100644 index 00000000..18df0b31 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app.py @@ -0,0 +1,87 @@ +""" +Google ADK Demo Application +演示 Agent、Tool、LLM 的集成使用 +""" +from google.adk.agents import Agent +from google.adk.tools import FunctionTool +from google.adk.runners import Runner +from datetime import datetime +import json + + +# 定义工具函数 +def get_current_time() -> str: + """获取当前时间""" + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def calculate(expression: str) -> str: + """ + 计算数学表达式 + + Args: + expression: 数学表达式,例如 "2 + 3" + """ + try: + result = eval(expression) + return f"计算结果:{result}" + except Exception as e: + return f"计算错误:{str(e)}" + + +# 创建 Tools +time_tool = FunctionTool( + name="get_current_time", + description="获取当前时间", + func=get_current_time +) + +calculator_tool = FunctionTool( + name="calculate", + description="计算数学表达式,支持加减乘除等基本运算", + func=calculate +) + +# 创建 Agent +math_assistant = Agent( + name="math_assistant", + description="一个能够执行数学计算和查询时间的智能助手", + tools=[time_tool, calculator_tool], + model="gemini-1.5-flash", # 或使用其他支持的模型 + instruction="你是一个专业的数学助手,可以帮助用户进行计算和查询时间。" +) + +# 创建 Runner +runner = Runner(app_name="math_assistant_demo", agent=math_assistant) + + +def main(): + """主函数""" + print("Google ADK Demo - Math Assistant") + print("=" * 50) + + # 测试场景 1:计算 + print("\n场景 1:数学计算") + result1 = runner.run("帮我计算 (125 + 375) * 2 的结果") + print(f"用户:帮我计算 (125 + 375) * 2 的结果") + print(f"助手:{result1}") + + # 测试场景 2:查询时间 + print("\n场景 2:查询时间") + result2 = runner.run("现在几点了?") + print(f"用户:现在几点了?") + print(f"助手:{result2}") + + # 测试场景 3:组合使用 + print("\n场景 3:组合使用") + result3 = runner.run("现在几点了?顺便帮我算一下 100 / 4") + print(f"用户:现在几点了?顺便帮我算一下 100 / 4") + print(f"助手:{result3}") + + print("\n" + "=" * 50) + print("Demo 完成") + + +if __name__ == "__main__": + main() + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app_service.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app_service.py new file mode 100644 index 00000000..36e083ec --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app_service.py @@ -0,0 +1,122 @@ +""" +Google ADK + FastAPI Service +将 Google ADK Agent 封装为 RESTful API 服务 +""" +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from google.adk.agents import Agent +from google.adk.tools import FunctionTool +from google.adk.runners import Runner +import uvicorn +from datetime import datetime + + +# 定义请求和响应模型 +class ChatRequest(BaseModel): + message: str + session_id: str = None + user_id: str = None + + +class ChatResponse(BaseModel): + response: str + session_id: str + token_usage: dict = None + + +# 创建 FastAPI 应用 +app = FastAPI(title="Google ADK API Service") + + +# 定义工具 +def get_weather(city: str) -> str: + """获取城市天气(模拟)""" + # 实际应用中这里应该调用真实的天气API + return f"{city}的天气:晴,温度25°C" + + +def search_knowledge(query: str) -> str: + """搜索知识库(模拟)""" + # 实际应用中这里应该连接真实的知识库 + return f"关于'{query}'的知识:这是模拟的知识库返回结果" + + +# 创建 Tools +weather_tool = FunctionTool( + name="get_weather", + description="获取指定城市的天气信息", + func=get_weather +) + +knowledge_tool = FunctionTool( + name="search_knowledge", + description="搜索内部知识库", + func=search_knowledge +) + +# 创建 Agent +assistant_agent = Agent( + name="customer_service_agent", + description="智能客服助手,可以查询天气和搜索知识库", + tools=[weather_tool, knowledge_tool], + model="gemini-1.5-flash", + system_instruction="你是一个专业的客服助手,态度友好,回答准确。" +) + +# 创建 Runner +runner = Runner(agent=assistant_agent) + + +# API 端点 +@app.get("/") +def root(): + """健康检查""" + return { + "service": "Google ADK API Service", + "status": "running", + "timestamp": datetime.now().isoformat() + } + + +@app.post("/chat", response_model=ChatResponse) +def chat(request: ChatRequest): + """ + 处理聊天请求 + + Args: + request: 包含用户消息和会话信息的请求 + + Returns: + ChatResponse: 包含 Agent 响应的结果 + """ + try: + # 执行 Agent + response = runner.run( + request.message, + session_id=request.session_id, + user_id=request.user_id + ) + + return ChatResponse( + response=response, + session_id=request.session_id or "default", + token_usage={"note": "Token usage info would be here"} + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/health") +def health(): + """健康检查端点""" + return {"status": "healthy"} + + +if __name__ == "__main__": + # 启动服务 + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + log_level="info" + ) diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/main.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/main.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/simple_demo.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/simple_demo.py new file mode 100644 index 00000000..cfe42c99 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/simple_demo.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Google ADK 工具使用精简示例 +展示如何在 ADK Agent 中使用工具函数 +""" + +import os +import sys +import asyncio +import math +import random +from datetime import datetime +from typing import List, Dict, Any + +# ==================== 工具函数定义 ==================== + +def get_current_time() -> str: + """获取当前时间""" + return f"当前时间是: {datetime.now().strftime('%Y年%m月%d日 %H:%M:%S')}" + +def calculate_math(expression: str) -> str: + """数学计算工具""" + 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: + """掷骰子""" + if sides < 2: + sides = 6 + return random.randint(1, sides) + +def check_prime_numbers(numbers: List[int]) -> Dict[str, Any]: + """检查质数""" + 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 + + primes = [num for num in numbers if is_prime(num)] + non_primes = [num for num in numbers if not is_prime(num)] + + return { + "primes": primes, + "non_primes": non_primes, + "summary": f"质数: {primes}, 非质数: {non_primes}" + } + +def get_weather_info(city: str) -> str: + """获取天气信息(模拟)""" + weather_data = { + "北京": "晴朗,温度 15°C", + "上海": "多云,温度 18°C", + "深圳": "小雨,温度 25°C", + "杭州": "阴天,温度 20°C" + } + weather = weather_data.get(city, f"{city}的天气信息暂时无法获取") + return f"{city}的天气:{weather}" + +# ==================== ADK Agent 设置 ==================== + +async def create_agent(): + """创建带工具的 ADK Agent""" + from google.adk.agents import LlmAgent + from google.adk.models.lite_llm import LiteLlm + from google.adk.tools import FunctionTool + + # 检查环境变量 + api_key = os.getenv('DASHSCOPE_API_KEY') + if not api_key: + print("❌ 请设置 DASHSCOPE_API_KEY 环境变量") + print(" export DASHSCOPE_API_KEY='your-api-key'") + sys.exit(1) + + # 创建模型 + model = LiteLlm( + model="dashscope/qwen-plus", + api_key=api_key, + temperature=0.7, + max_tokens=1000, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" + ) + + # 创建工具 + tools = [ + FunctionTool(func=get_current_time), + FunctionTool(func=calculate_math), + FunctionTool(func=roll_dice), + FunctionTool(func=check_prime_numbers), + FunctionTool(func=get_weather_info) + ] + + # 创建 Agent + agent = LlmAgent( + name="simple_assistant", + model=model, + instruction="""你是一个智能助手,可以使用多种工具帮助用户。 +可用工具: +1. get_current_time - 获取当前时间 +2. calculate_math - 数学计算 +3. roll_dice - 掷骰子 +4. check_prime_numbers - 检查质数 +5. get_weather_info - 获取天气 + +用中文友好地与用户交流,根据需要调用工具。""", + description="一个简单的工具助手", + tools=tools + ) + + return agent + +async def run_conversation(user_input: str) -> str: + """运行对话并返回回复""" + from google.adk.runners import Runner + from google.adk.sessions.in_memory_session_service import InMemorySessionService + from google.genai import types + + # 初始化服务 + session_service = InMemorySessionService() + agent = await create_agent() + runner = Runner( + app_name="simple_demo", + agent=agent, + session_service=session_service + ) + + # 创建会话 + session = await session_service.create_session( + app_name="simple_demo", + user_id="demo_user", + session_id=f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + + # 创建用户消息 + user_message = types.Content( + role="user", + parts=[types.Part(text=user_input)] + ) + + # 运行对话并收集事件 + events = [] + async for event in runner.run_async( + user_id="demo_user", + session_id=session.id, + new_message=user_message + ): + events.append(event) + + # 提取回复文本 + for event in events: + if hasattr(event, 'content') and event.content: + if hasattr(event.content, 'parts') and event.content.parts: + text_parts = [part.text for part in event.content.parts if hasattr(part, 'text') and part.text] + if text_parts: + return ''.join(text_parts) + + return "未收到有效回复" + +# ==================== 主程序 ==================== + +async def main(): + """主函数""" + print("🚀 Google ADK 工具使用精简示例") + print("=" * 50) + + # 测试用例 + test_cases = [ + "现在几点了?", + "计算 123 乘以 456", + "掷一个六面骰子", + "检查 17, 25, 29 是否为质数", + "北京的天气怎么样?" + ] + + for i, user_input in enumerate(test_cases, 1): + print(f"\n💬 测试 {i}: {user_input}") + print("-" * 40) + + try: + response = await run_conversation(user_input) + print(f"🤖 回复: {response}") + except Exception as e: + print(f"❌ 错误: {e}") + + # 避免请求过快 + if i < len(test_cases): + await asyncio.sleep(1) + + print("\n✅ 所有测试完成") + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n👋 程序已停止") + except Exception as e: + print(f"❌ 运行失败: {e}") + import traceback + traceback.print_exc() + + diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/tools.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/examples/tools.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/pyproject.toml similarity index 81% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/pyproject.toml index 25929cdb..6138262f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-adk/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/pyproject.toml @@ -3,14 +3,14 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "opentelemetry-instrumentation-google-adk" -version = "0.1.0" +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.8" authors = [ - { name = "OpenTelemetry Contributors" }, + { name = "LoongSuite Python Agent Authors" }, ] classifiers = [ "Development Status :: 4 - Beta", @@ -45,15 +45,18 @@ instruments = [ ] [project.urls] -Homepage = "https://github.com/alibaba/loongsuite-python-agent" +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", - "/tests", + "src", ] [tool.hatch.build.targets.wheel] diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/__init__.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py diff --git a/instrumentation-genai/opentelemetry-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 similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py diff --git a/instrumentation-genai/opentelemetry-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 similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py diff --git a/instrumentation-genai/opentelemetry-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 similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/__init__.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/__init__.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/__init__.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_metrics.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_metrics.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_plugin_integration.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_plugin_integration.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_plugin_integration.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_plugin_integration.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_utils.py similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-google-adk/tests/test_utils.py rename to instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_utils.py diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 08d66b8c..2ea6f1bf 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -16,10 +16,6 @@ # RUN `python scripts/generate_instrumentation_bootstrap.py` TO REGENERATE. libraries = [ - { - "library": "google-adk >= 0.1.0", - "instrumentation": "opentelemetry-instrumentation-google-adk==0.1.0", - }, { "library": "openai >= 1.26.0", "instrumentation": "opentelemetry-instrumentation-openai-v2", From b7740a3f13994dc566aa3f93b07f250c225957c9 Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Thu, 27 Nov 2025 09:56:55 +0800 Subject: [PATCH 07/11] chore: remove unused files. Change-Id: I088389a1c453e068e29bdd64f5e18f5bcce60787 Co-developed-by: Cursor --- .../docs/ARMS_GOOGLE_ADK_USER_GUIDE.md | 894 ------------------ .../docs/MIGRATION_SUMMARY.md | 288 ------ .../docs/migration-plan.md | 220 ----- .../docs/trace-metrics-comparison.md | 674 ------------- .../examples/adk_app.py | 87 -- .../examples/adk_app_service.py | 122 --- .../examples/simple_demo.py | 212 ----- 7 files changed, 2497 deletions(-) delete mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/ARMS_GOOGLE_ADK_USER_GUIDE.md delete mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/MIGRATION_SUMMARY.md delete mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/migration-plan.md delete mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/trace-metrics-comparison.md delete mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app.py delete mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app_service.py delete mode 100644 instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/simple_demo.py diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/ARMS_GOOGLE_ADK_USER_GUIDE.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/ARMS_GOOGLE_ADK_USER_GUIDE.md deleted file mode 100644 index a0bc29f5..00000000 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/ARMS_GOOGLE_ADK_USER_GUIDE.md +++ /dev/null @@ -1,894 +0,0 @@ -# 使用 ARMS Python 探针监控 Google ADK 应用 - -更新时间:2025-10-24 - -## 背景信息 - -Google ADK (Agent Development Kit) 是 Google 推出的用于构建 GenAI Agent 应用的开发框架。通过 Google ADK,开发者可以快速构建具有工具调用、多轮对话、状态管理等能力的智能 Agent 应用。 - -ARMS Python 探针是阿里云应用实时监控服务(ARMS)自研的 Python 语言可观测采集探针,基于 OpenTelemetry 标准实现了自动化埋点能力,完整支持 Google ADK 应用的追踪和监控。 - -将 Google ADK 应用接入 ARMS 后,您可以: -- 查看 Agent 调用链视图,直观分析 Agent 的执行流程 -- 监控工具调用(Tool Call)的输入输出和执行耗时 -- 追踪 LLM 模型请求的详细信息,包括 Token 消耗、响应时间等 -- 实时监控应用性能指标,及时发现和定位问题 -- 追踪 A2A 通讯的细节 - -ARMS 支持的 LLM(大语言模型)推理服务框架和应用框架,请参见 [ARMS 应用监控支持的 Python 组件和框架](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/install-arms-agent-for-python-applications-deployed-in-ack-and-acs)。 - -## 前提条件 - -- 已开通 ARMS 服务。如未开通,请参见[开通 ARMS 服务](https://help.aliyun.com/zh/arms/application-monitoring/getting-started/activate-arms)。 -- 已安装 Python 3.8 及以上版本。 -- 已安装 Google ADK(`google-adk>=0.1.0`)。 - -## 安装 ARMS Python 探针 - -根据 Google ADK 应用部署环境选择合适的安装方式: - -### 容器环境安装 - -如果您的应用部署在容器服务 ACK 或容器计算服务 ACS 上,可以通过 ack-onepilot 组件自动安装 ARMS Python 探针。具体操作,请参见[通过 ack-onepilot 组件安装 Python 探针](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/install-the-arms-agent-for-python-applications-deployed-in-container-service-for-kubernetes)。 - -### 手动安装 - -1. 安装 ARMS Python 探针: - -```bash -pip install aliyun-bootstrap -``` - -2. 安装 Google ADK 及相关依赖: - -```bash -# 安装 Google ADK -pip install google-adk>=0.1.0 - -# 安装 LLM 客户端库(根据实际使用选择) -pip install litellm # 用于统一的 LLM API 调用 -``` - -## 接入 ARMS - -### 启动应用 - -使用 ARMS Python 探针启动您的 Google ADK 应用: - -```bash -aliyun-instrument python your_adk_app.py -``` - -**说明**: -- 将 `your_adk_app.py` 替换为您的实际应用入口文件。 -- ARMS Python 探针会自动识别 Google ADK 应用并进行埋点。 -- 如果您暂时没有可接入的 Google ADK 应用,可以使用本文档附录提供的应用 Demo。 - -### 配置环境变量 - -在启动应用前,您可以配置以下环境变量: - -```bash -# ARMS 接入配置 -export ARMS_APP_NAME=xxx # 应用名称。 -export ARMS_REGION_ID=xxx # 对应的阿里云账号的RegionID。 -export ARMS_LICENSE_KEY=xxx # 阿里云 LicenseKey。 - -# GenAI 相关配置 -export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true - -# 启动应用 -aliyun-instrument python your_adk_app.py -``` - -**配置说明**: -- `APSARA_APM_ACCESS_KEY_ID`:您的阿里云 AccessKey ID -- `APSARA_APM_ACCESS_KEY_SECRET`:您的阿里云 AccessKey Secret -- `APSARA_APM_REGION_ID`:ARMS 服务所在地域,例如 `cn-hangzhou` -- `APSARA_APM_SERVICE_NAME`:应用名称,用于在 ARMS 控制台中标识您的应用 - -## 执行结果 - -约一分钟后,若 Google ADK 应用出现在 ARMS 控制台的 **LLM 应用监控** > **应用列表** 页面中且有数据上报,则说明接入成功。 - - -**图 1:ARMS 控制台 - LLM 应用列表** - -[预留截图位置] - ---- - -## 查看监控数据 - -### 调用链视图 - -在 ARMS 控制台的 **LLM 应用监控** > **调用链** 页面,您可以查看 Google ADK 应用的详细调用链路: - - -**图 2:Google ADK 应用调用链列表** - -[预留截图位置] - ---- - -点击具体的调用链,可以查看完整的 Span 信息,包括: - -- **Agent Span**:Agent 执行的完整流程 - - `gen_ai.operation.name`: `invoke_agent` - - `gen_ai.agent.name`: Agent 名称 - - `gen_ai.agent.description`: Agent 描述 - - `gen_ai.conversation.id`: 会话 ID - - `enduser.id`: 用户 ID - -- **LLM Span**:模型调用详情 - - `gen_ai.operation.name`: `chat` - - `gen_ai.provider.name`: 模型提供商 - - `gen_ai.request.model`: 请求模型名称 - - `gen_ai.response.model`: 响应模型名称 - - `gen_ai.usage.input_tokens`: 输入 Token 数 - - `gen_ai.usage.output_tokens`: 输出 Token 数 - - `gen_ai.response.finish_reasons`: 完成原因 - -- **Tool Span**:工具调用详情 - - `gen_ai.operation.name`: `execute_tool` - - `gen_ai.tool.name`: 工具名称 - - `gen_ai.tool.description`: 工具描述 - - `gen_ai.tool.call.arguments`: 工具调用参数 - - `gen_ai.tool.call.result`: 工具返回结果 - - -**图 3:调用链详情 - 展示 Agent、LLM、Tool 的层级关系** - -[预留截图位置] - ---- - -### 性能指标 - -在 **LLM 应用监控** > **指标** 页面,您可以查看应用的性能指标: - -#### 调用次数(genai_calls_count) - -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:次 -- **维度**: - - `modelName`:模型名称 - - `spanKind`:Span 类型(LLM、AGENT、TOOL) - - `service`:服务名称 - - `rpc`:调用名称 - - -**图 4:GenAI 调用次数统计** - -[预留截图位置] - ---- - -#### 响应耗时(genai_calls_duration_seconds) - -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:秒 -- **维度**: - - `modelName`:模型名称 - - `spanKind`:Span 类型(LLM、AGENT、TOOL) - - `service`:服务名称 - - `rpc`:调用名称 - - -**图 5:GenAI 响应耗时分布** - -[预留截图位置] - ---- - -#### Token 使用量(genai_llm_usage_tokens) - -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:token -- **维度**: - - `modelName`:模型名称 - - `spanKind`:Span 类型(通常为 LLM) - - `usageType`:Token 类型(input、output) - - `service`:服务名称 - - `rpc`:调用名称 - - -**图 6:Token 使用量统计** - -[预留截图位置] - ---- - -#### 首包响应时间(genai_llm_first_token_seconds) - -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:秒 -- **说明**:从 LLM 请求发出到收到第一个 Token 的耗时(TTFT - Time To First Token) -- **维度**: - - `modelName`:模型名称 - - `spanKind`:Span 类型(LLM) - - `service`:服务名称 - - `rpc`:调用名称 - - -**图 7:LLM 首包响应时间** - -[预留截图位置] - ---- - -#### 错误统计(genai_calls_error_count) - -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:次 -- **维度**: - - `modelName`:模型名称 - - `spanKind`:Span 类型(LLM、AGENT、TOOL) - - `service`:服务名称 - - `rpc`:调用名称 - - -**图 8:GenAI 错误统计** - -[预留截图位置] - ---- - -#### 慢调用统计(genai_calls_slow_count) - -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:次 -- **维度**: - - `modelName`:模型名称 - - `spanKind`:Span 类型(LLM、AGENT、TOOL) - - `service`:服务名称 - - `rpc`:调用名称 - - -**图 9:GenAI 慢调用统计** - -[预留截图位置] - ---- - -### LLM 调用链分析 - -ARMS 提供专门的 LLM 调用链分析功能,支持: - -- **输入输出分析**:查看每次 LLM 调用的完整 prompt 和 response -- **Token 成本分析**:统计和分析 Token 消耗情况 -- **性能分析**:分析响应时间、首 Token 时间等性能指标 -- **错误分析**:快速定位和诊断 LLM 调用错误 - -更多信息,请参见 [LLM 调用链分析](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/llm-call-chain-analysis)。 - - -**图 6:LLM 调用链分析** - -[预留截图位置] - ---- - -## 配置选项 - -### 输入/输出内容采集 - -**默认值**:`False`,默认不采集详细内容。 - -**配置说明**: -- 开启后:采集 Agent、Tool、LLM 的完整输入输出内容 -- 关闭后:仅采集字段大小,不采集字段内容 - -**配置方式**: - -```bash -export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true -``` - -**注意**:采集内容可能包含敏感信息,请根据实际需求和安全要求决定是否开启。 - -### 消息内容字段长度限制 - -**默认值**:4096 字符 - -**配置说明**:限制每条消息内容的最大长度,超过限制的内容将被截断。 - -**配置方式**: - -```bash -export OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH=8192 -``` - -### Span 属性值长度限制 - -**默认值**:无限制 - -**配置说明**:限制上报的 Span 属性值(如 `gen_ai.agent.description`)的长度,超过限制的内容将被截断。 - -**配置方式**: - -```bash -export OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT=4096 -``` - -### 应用类型指定 - -ARMS Python 探针会自动识别应用类型,但您也可以手动指定: - -```bash -# app: 大语言模型应用 -export APSARA_APM_APP_TYPE=app -``` - -## 语义规范说明 - -ARMS Python 探针完全遵循 OpenTelemetry GenAI 语义规范,确保监控数据的标准化和可移植性。 - -### Trace 语义规范 - -**Span 命名规范**: -- LLM 操作:`chat {model}`,例如 `chat gemini-pro` -- Agent 操作:`invoke_agent {agent_name}`,例如 `invoke_agent math_tutor` -- Tool 操作:`execute_tool {tool_name}`,例如 `execute_tool get_weather` - -**标准 Attributes**: -- `gen_ai.operation.name`:操作类型(必需) -- `gen_ai.provider.name`:提供商名称(必需) -- `gen_ai.conversation.id`:会话 ID(替代旧版 `gen_ai.session.id`) -- `enduser.id`:用户 ID(替代旧版 `gen_ai.user.id`) -- `gen_ai.response.finish_reasons`:完成原因(数组格式) - -更多信息,请参见: -- [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) - -### ARMS 监控指标 - -ARMS Python 探针会自动采集以下 GenAI 相关指标: - -#### 1. genai_calls_count -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:次 -- **说明**:各种 GenAI 相关调用的请求次数 -- **维度**: - - `modelName`:模型名称(必需) - - `spanKind`:Span 类型(必需),如 `LLM`、`AGENT`、`TOOL` - - `pid`:应用 ID - - `service`:服务名称 - - `serverIp`:机器 IP - - `rpc`:调用名称(spanName) - -#### 2. genai_calls_duration_seconds -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:秒 -- **说明**:各种 GenAI 相关调用的响应耗时 -- **维度**: - - `modelName`:模型名称(必需) - - `spanKind`:Span 类型(必需) - - 以及其他公共维度(pid、service、serverIp、rpc) - -#### 3. genai_calls_error_count -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:次 -- **说明**:各种 GenAI 相关调用的错误次数 -- **维度**: - - `modelName`:模型名称(必需) - - `spanKind`:Span 类型(必需) - - 以及其他公共维度(pid、service、serverIp、rpc) - -#### 4. genai_calls_slow_count -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:次 -- **说明**:各种 GenAI 相关调用的慢调用次数 -- **维度**: - - `modelName`:模型名称(必需) - - `spanKind`:Span 类型(必需) - - 以及其他公共维度(pid、service、serverIp、rpc) - -#### 5. genai_llm_first_token_seconds -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:秒 -- **说明**:调用 LLM 首包响应耗时(从请求到第一个响应返回的耗时) -- **适用范围**:大模型应用和模型服务 -- **维度**: - - `modelName`:模型名称(必需) - - `spanKind`:Span 类型(必需) - - 以及其他公共维度(pid、service、serverIp、rpc) - -#### 6. genai_llm_usage_tokens -- **指标类型**:Gauge -- **采集间隔**:1 分钟 -- **单位**:token -- **说明**:Tokens 消耗统计 -- **维度**: - - `modelName`:模型名称(必需) - - `spanKind`:Span 类型(必需) - - `usageType`:用途类型(必需),取值为 `input` 或 `output` - - 以及其他公共维度(pid、service、serverIp、rpc) - -#### 公共维度说明 - -所有 GenAI 指标都包含以下公共维度: - -| 维度Key | 维度描述 | 类型 | 示例 | 需求等级 | -|--------|---------|------|------|---------| -| `pid` | 应用 ID | string | `ggxw4lnjuz@0cb8619bb54****` | 必须 | -| `service` | 服务名称 | string | `llm-rag-demo` | 必须 | -| `serverIp` | 应用对应机器 IP | string | `127.0.0.1` | 可选 | -| `rpc` | 调用名称(spanName),工具调用为 toolName | string | `/query` | 必须 | -| `source` | 用户来源 | string | `apm` | 必须 | -| `acs_cms_workspace` | 云监控 Workspace | string | `arms-test` | 有条件时必须 | -| `acs_arms_service_id` | 云监控服务 ID | string | `ggxw4lnjuz@b63ba5a1d60b517ae374f` | 有条件时必须 | - -**注意**: -- `source` 取值为 `apm`(ARMS 应用实时监控服务)或 `xtrace`(可观测链路 OpenTelemetry 版) -- `spanKind` 用于区分不同类型的 GenAI 操作:`LLM`(大模型调用)、`AGENT`(Agent 调用)、`TOOL`(工具调用)等 -- 所有指标均为大模型调用记录为内部调用(CallType: `internal`),通过 `spanKind` 进行聚合 - -## 附录:Demo 示例 - -### 示例程序架构流程图 - -本章节的示例程序基于 Google ADK 框架,实现了一个完整的工具使用 Agent HTTP 服务。以下是其核心执行流程: - -```mermaid -sequenceDiagram - autonumber - participant User as 👤 用户/客户端 - participant FastAPI as 🌐 FastAPI 服务 - participant Runner as 🏃 ADK Runner - participant Agent as 🤖 LLM Agent - participant LLM as 🧠 百炼模型
(qwen-plus) - participant Tools as 🔧 工具集 - participant ARMS as 📊 ARMS 监控平台 - - Note over FastAPI,ARMS: ARMS Python 探针自动注入
捕获所有 trace 和 metrics 数据 - - User->>FastAPI: POST /tools
{task: "现在几点了?"} - activate FastAPI - - FastAPI->>Runner: 调用 run_async() - activate Runner - - Runner->>Agent: 创建用户消息 - activate Agent - - Agent->>LLM: 发送任务给 LLM 模型 - activate LLM - Note over LLM: LLM 理解任务
决定需要调用工具 - LLM-->>Agent: 返回工具调用决策
execute_tool("get_current_time") - deactivate LLM - - Agent->>Tools: 调用 get_current_time() - activate Tools - Note over Tools: 执行工具函数
获取系统时间 - Tools-->>Agent: 返回当前时间结果 - deactivate Tools - - Agent->>LLM: 发送工具结果给 LLM - activate LLM - Note over LLM: LLM 整合工具结果
生成最终回答 - LLM-->>Agent: 返回最终答案 - deactivate LLM - - Agent-->>Runner: 返回对话结果 - deactivate Agent - - Runner-->>FastAPI: 返回响应内容 - deactivate Runner - - FastAPI-->>User: 返回 JSON 响应
{success: true, data: {...}} - deactivate FastAPI - - Note over ARMS: 📊 ARMS 自动捕获:
✅ Span:LLM 请求、Agent 调用、Tool 执行
✅ Metrics:操作耗时、Token 消耗
✅ Trace:完整的调用链路 -``` - -**流程说明:** - -1. **用户请求**:客户端通过 HTTP POST 请求发送任务到 FastAPI 服务(如"现在几点了?") -2. **ADK Runner 处理**:Runner 接收请求并创建用户消息 -3. **Agent 协调**:Agent 将任务发送给 LLM 模型进行理解 -4. **LLM 决策**:LLM 分析任务并决定需要调用 `get_current_time()` 工具 -5. **工具执行**:Agent 调用相应的工具函数获取当前时间 -6. **结果整合**:Agent 将工具返回的结果再次发送给 LLM -7. **生成回答**:LLM 基于工具结果生成最终的自然语言回答 -8. **响应返回**:完整的响应通过 FastAPI 返回给客户端 -9. **ARMS 监控**:整个过程中,ARMS Python 探针自动捕获所有的 Trace、Span 和 Metrics 数据 - -**可用工具集:** - -本示例程序集成了 7 个工具函数,展示了 Agent 的多种能力: - -| 工具名称 | 功能描述 | 示例任务 | -|---------|---------|---------| -| 🕐 `get_current_time` | 获取当前时间 | "现在几点了?" | -| 🧮 `calculate_math` | 数学表达式计算 | "计算 123 * 456" | -| 🎲 `roll_dice` | 掷骰子(可指定面数) | "掷一个六面骰子" | -| 🔢 `check_prime_numbers` | 质数检查 | "检查 17, 25, 29 是否为质数" | -| 🌤️ `get_weather_info` | 获取天气信息(模拟) | "北京的天气怎么样?" | -| 🔍 `search_web` | 网络搜索(模拟) | "搜索人工智能的定义" | -| 🌍 `translate_text` | 文本翻译(模拟) | "翻译'你好'成英文" | - -**ARMS 监控维度:** - -探针会自动为以下操作生成对应的 Span 和 Metrics: - -**Span 数据:** -- **LLM 请求 Span**:包含模型名称、Token 消耗、响应时间等 -- **Agent 调用 Span**:包含 Agent 名称、操作类型、会话 ID 等 -- **Tool 执行 Span**:包含工具名称、参数、返回值等 - -**Metrics 数据:** -- **genai_calls_count**:GenAI 调用请求次数(按 spanKind 区分:LLM、AGENT、TOOL) -- **genai_calls_duration_seconds**:GenAI 调用响应耗时 -- **genai_calls_error_count**:GenAI 调用错误次数 -- **genai_calls_slow_count**:GenAI 慢调用次数 -- **genai_llm_first_token_seconds**:LLM 首包响应耗时(TTFT) -- **genai_llm_usage_tokens**:Token 消耗统计(区分 input/output) - -完整的示例代码请参见项目的 `examples/` 目录([main.py](../examples/main.py) 和 [tools.py](../examples/tools.py))。 - -### Google ADK 基础示例 - -本示例演示如何创建一个简单的 Google ADK Agent 应用。 - -#### 应用代码(adk_app.py) - -```python -""" -Google ADK Demo Application -演示 Agent、Tool、LLM 的集成使用 -""" -from google.adk.agents import Agent -from google.adk.tools import Tool, FunctionTool -from google.adk.runners import Runner -from datetime import datetime -import json - - -# 定义工具函数 -def get_current_time() -> str: - """获取当前时间""" - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - -def calculate(expression: str) -> str: - """ - 计算数学表达式 - - Args: - expression: 数学表达式,例如 "2 + 3" - """ - try: - result = eval(expression) - return f"计算结果:{result}" - except Exception as e: - return f"计算错误:{str(e)}" - - -# 创建 Tools -time_tool = FunctionTool( - name="get_current_time", - description="获取当前时间", - func=get_current_time -) - -calculator_tool = FunctionTool( - name="calculate", - description="计算数学表达式,支持加减乘除等基本运算", - func=calculate -) - -# 创建 Agent -math_assistant = Agent( - name="math_assistant", - description="一个能够执行数学计算和查询时间的智能助手", - tools=[time_tool, calculator_tool], - model="gemini-1.5-flash", # 或使用其他支持的模型 - system_instruction="你是一个专业的数学助手,可以帮助用户进行计算和查询时间。" -) - -# 创建 Runner -runner = Runner(agent=math_assistant) - - -def main(): - """主函数""" - print("Google ADK Demo - Math Assistant") - print("=" * 50) - - # 测试场景 1:计算 - print("\n场景 1:数学计算") - result1 = runner.run("帮我计算 (125 + 375) * 2 的结果") - print(f"用户:帮我计算 (125 + 375) * 2 的结果") - print(f"助手:{result1}") - - # 测试场景 2:查询时间 - print("\n场景 2:查询时间") - result2 = runner.run("现在几点了?") - print(f"用户:现在几点了?") - print(f"助手:{result2}") - - # 测试场景 3:组合使用 - print("\n场景 3:组合使用") - result3 = runner.run("现在几点了?顺便帮我算一下 100 / 4") - print(f"用户:现在几点了?顺便帮我算一下 100 / 4") - print(f"助手:{result3}") - - print("\n" + "=" * 50) - print("Demo 完成") - - -if __name__ == "__main__": - main() -``` - -#### 依赖文件(requirements.txt) - -```txt -google-adk>=0.1.0 -litellm -aliyun-python-agent -``` - -#### 运行方式 - -```bash -# 1. 安装依赖 -pip install -r requirements.txt - -# 2. 配置 ARMS 环境变量 -export APSARA_APM_ACCESS_KEY_ID=<您的AccessKey ID> -export APSARA_APM_ACCESS_KEY_SECRET=<您的AccessKey Secret> -export APSARA_APM_REGION_ID=cn-hangzhou -export APSARA_APM_SERVICE_NAME=google-adk-demo - -# 3. 配置 GenAI 内容采集 -export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true - -# 4. 配置模型 API(根据使用的模型选择) -export GEMINI_API_KEY=<您的 Gemini API Key> -# 或使用 DashScope -export DASHSCOPE_API_KEY=<您的 DashScope API Key> - -# 5. 使用 ARMS 探针启动应用 -aliyun-instrument python adk_app.py -``` - -### Google ADK + FastAPI 服务示例 - -本示例演示如何将 Google ADK Agent 封装为 Web API 服务。 - -#### 应用代码(adk_api_service.py) - -```python -""" -Google ADK + FastAPI Service -将 Google ADK Agent 封装为 RESTful API 服务 -""" -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from google.adk.agents import Agent -from google.adk.tools import FunctionTool -from google.adk.runners import Runner -import uvicorn -from datetime import datetime - - -# 定义请求和响应模型 -class ChatRequest(BaseModel): - message: str - session_id: str = None - user_id: str = None - - -class ChatResponse(BaseModel): - response: str - session_id: str - token_usage: dict = None - - -# 创建 FastAPI 应用 -app = FastAPI(title="Google ADK API Service") - - -# 定义工具 -def get_weather(city: str) -> str: - """获取城市天气(模拟)""" - # 实际应用中这里应该调用真实的天气API - return f"{city}的天气:晴,温度25°C" - - -def search_knowledge(query: str) -> str: - """搜索知识库(模拟)""" - # 实际应用中这里应该连接真实的知识库 - return f"关于'{query}'的知识:这是模拟的知识库返回结果" - - -# 创建 Tools -weather_tool = FunctionTool( - name="get_weather", - description="获取指定城市的天气信息", - func=get_weather -) - -knowledge_tool = FunctionTool( - name="search_knowledge", - description="搜索内部知识库", - func=search_knowledge -) - -# 创建 Agent -assistant_agent = Agent( - name="customer_service_agent", - description="智能客服助手,可以查询天气和搜索知识库", - tools=[weather_tool, knowledge_tool], - model="gemini-1.5-flash", - system_instruction="你是一个专业的客服助手,态度友好,回答准确。" -) - -# 创建 Runner -runner = Runner(agent=assistant_agent) - - -# API 端点 -@app.get("/") -def root(): - """健康检查""" - return { - "service": "Google ADK API Service", - "status": "running", - "timestamp": datetime.now().isoformat() - } - - -@app.post("/chat", response_model=ChatResponse) -def chat(request: ChatRequest): - """ - 处理聊天请求 - - Args: - request: 包含用户消息和会话信息的请求 - - Returns: - ChatResponse: 包含 Agent 响应的结果 - """ - try: - # 执行 Agent - response = runner.run( - request.message, - session_id=request.session_id, - user_id=request.user_id - ) - - return ChatResponse( - response=response, - session_id=request.session_id or "default", - token_usage={"note": "Token usage info would be here"} - ) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/health") -def health(): - """健康检查端点""" - return {"status": "healthy"} - - -if __name__ == "__main__": - # 启动服务 - uvicorn.run( - app, - host="0.0.0.0", - port=8000, - log_level="info" - ) -``` - -#### 依赖文件(requirements.txt) - -```txt -google-adk>=0.1.0 -fastapi -uvicorn[standard] -pydantic -litellm -aliyun-python-agent -``` - -#### 运行方式 - -```bash -# 1. 安装依赖 -pip install -r requirements.txt - -# 2. 配置环境变量 -export APSARA_APM_ACCESS_KEY_ID=<您的AccessKey ID> -export APSARA_APM_ACCESS_KEY_SECRET=<您的AccessKey Secret> -export APSARA_APM_REGION_ID=cn-hangzhou -export APSARA_APM_SERVICE_NAME=google-adk-api-service -export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true -export GEMINI_API_KEY=<您的 Gemini API Key> - -# 3. 使用 ARMS 探针启动服务 -aliyun-instrument python adk_api_service.py -``` - -#### 测试 API - -```bash -# 测试健康检查 -curl http://localhost:8000/health - -# 测试聊天接口 -curl -X POST http://localhost:8000/chat \ - -H "Content-Type: application/json" \ - -d '{ - "message": "北京今天天气怎么样?", - "session_id": "session_001", - "user_id": "user_123" - }' -``` - -## 常见问题 - -### 1. 应用未出现在 ARMS 控制台 - -**问题排查**: -- 检查 AccessKey 配置是否正确 -- 检查地域(Region ID)配置是否正确 -- 检查网络连接,确保应用可以访问 ARMS 服务端点 -- 查看应用日志,确认探针是否正常启动 - -### 2. 调用链数据缺失 - -**问题排查**: -- 检查 `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` 配置 -- 确认 Google ADK 版本是否符合要求(>=0.1.0) -- 检查是否有异常或错误日志 - -### 3. Token 使用量数据为空 - -**可能原因**: -- 部分模型可能不返回 Token 使用量信息 -- 需要确保模型 API 响应中包含 usage 信息 - -### 4. 性能影响 - -**说明**: -- ARMS Python 探针采用异步上报机制,对应用性能影响极小(通常 < 1%) -- 如需进一步降低影响,可以关闭内容采集:`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=false` - -## 相关文档 - -- [ARMS 应用监控概述](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/application-monitoring-overview) -- [LLM 调用链分析](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/llm-call-chain-analysis) -- [ARMS Python 探针总览](https://help.aliyun.com/zh/arms/application-monitoring/user-guide/use-the-arms-agent-for-python-to-monitor-llm-applications) -- [OpenTelemetry GenAI 语义规范](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/) -- [Google ADK 官方文档](https://google.github.io/adk-docs/) - -## 技术支持 - -如果您在使用过程中遇到问题,可以通过以下方式获取帮助: - -- 提交工单:在阿里云控制台提交技术支持工单 -- 钉钉群:加入 ARMS 技术交流群 -- 文档反馈:通过文档页面的反馈按钮提交问题 - ---- - -**最后更新时间**:2025-10-24 -**文档版本**:v1.0 - diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/MIGRATION_SUMMARY.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/MIGRATION_SUMMARY.md deleted file mode 100644 index 2da4acbb..00000000 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/MIGRATION_SUMMARY.md +++ /dev/null @@ -1,288 +0,0 @@ -# Google ADK 插件迁移总结 - -## 迁移完成状态 - -✅ **所有 6 个阶段已完成!** - ---- - -## 📋 迁移概览 - -### 已完成的阶段 - -| 阶段 | 状态 | 说明 | -|------|------|------| -| **Phase 1: Trace 核心变更** | ✅ 完成 | `gen_ai.system` → `gen_ai.provider.name`
移除 `gen_ai.span.kind`
移除 `gen_ai.framework` | -| **Phase 2: Trace 属性标准化** | ✅ 完成 | Agent/Tool 属性标准化
`session.id` → `conversation.id`
`user.id` → `enduser.id` | -| **Phase 3: 内容捕获机制** | ✅ 完成 | 实现标准 `process_content()`
环境变量控制
移除 ARMS SDK 依赖 | -| **Phase 4: Metrics 完全重构** | ✅ 完成 | 12 个指标 → 2 个标准指标
所有维度标准化
移除高基数属性 | -| **Phase 5: 测试重写** | ✅ 完成 | Extractors 测试
Metrics 测试 | -| **Phase 6: 文档和示例** | ✅ 完成 | README.md
迁移对比文档 | - ---- - -## 🎯 关键变更总结 - -### 1. 命名空间变更 - -```python -# ❌ 商业版本 -from aliyun.instrumentation.google_adk import AliyunGoogleAdkInstrumentor - -# ✅ 开源版本 -from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor -``` - -### 2. 核心属性变更 - -| 商业版本 | 开源版本 | 状态 | -|---------|---------|------| -| `gen_ai.system` | `gen_ai.provider.name` | ✅ 已修改 | -| `gen_ai.span.kind` | (removed) | ✅ 已移除 | -| `gen_ai.framework` | (removed) | ✅ 已移除 | -| `gen_ai.session.id` | `gen_ai.conversation.id` | ✅ 已修改 | -| `gen_ai.user.id` | `enduser.id` | ✅ 已修改 | -| `gen_ai.model_name` | (removed) | ✅ 已移除 | -| `gen_ai.response.finish_reason` | `gen_ai.response.finish_reasons` | ✅ 已修改 | -| `gen_ai.usage.total_tokens` | (removed) | ✅ 已移除 | -| `gen_ai.request.is_stream` | (removed) | ✅ 已移除 | - -### 3. Agent/Tool 属性变更 - -| 商业版本 | 开源版本 | 状态 | -|---------|---------|------| -| `agent.name` | `gen_ai.agent.name` | ✅ 已修改 | -| `agent.description` | `gen_ai.agent.description` | ✅ 已修改 | -| `tool.name` | `gen_ai.tool.name` | ✅ 已修改 | -| `tool.description` | `gen_ai.tool.description` | ✅ 已修改 | -| `tool.parameters` | `gen_ai.tool.call.arguments` | ✅ 已修改 | - -### 4. Metrics 变更 - -#### 移除的指标(12个 → 0个) - -❌ **ARMS 专有指标**: -- `calls_count` -- `calls_duration_seconds` -- `call_error_count` -- `llm_usage_tokens` -- `llm_first_token_seconds` - -❌ **自定义 GenAI 指标**: -- `genai_calls_count` -- `genai_calls_duration_seconds` -- `genai_calls_error_count` -- `genai_calls_slow_count` -- `genai_llm_first_token_seconds` -- `genai_llm_usage_tokens` -- `genai_avg_first_token_seconds` - -#### 新增的标准指标(0个 → 2个) - -✅ **标准 OTel GenAI Client Metrics**: -1. `gen_ai.client.operation.duration` (Histogram, unit: seconds) -2. `gen_ai.client.token.usage` (Histogram, unit: tokens) - -#### Metrics 维度变更 - -| 商业版本 | 开源版本 | 状态 | -|---------|---------|------| -| `callType` | (removed) | ✅ 已移除 | -| `callKind` | (removed) | ✅ 已移除 | -| `rpcType` | (removed) | ✅ 已移除 | -| `rpc` | (removed) | ✅ 已移除 | -| `modelName` | `gen_ai.request.model` | ✅ 已修改 | -| `spanKind` | `gen_ai.operation.name` | ✅ 已修改 | -| `usageType` | `gen_ai.token.type` | ✅ 已修改 | -| `session_id` | (removed from metrics) | ✅ 已移除 | -| `user_id` | (removed from metrics) | ✅ 已移除 | - -### 5. 环境变量变更 - -| 商业版本 | 开源版本 | -|---------|---------| -| `ENABLE_GOOGLE_ADK_INSTRUMENTOR` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | -| (SDK internal) | `OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH` | - -### 6. 内容捕获机制变更 - -```python -# ❌ 商业版本 - 依赖 ARMS SDK -from aliyun.sdk.extension.arms.utils.capture_content import process_content - -# ✅ 开源版本 - 自实现标准机制 -from ._utils import process_content # 基于环境变量控制 -``` - ---- - -## 📁 文件结构 - -### 开源版本文件结构 - -``` -opentelemetry-instrumentation-google-adk/ -├── src/ -│ └── opentelemetry/ -│ └── instrumentation/ -│ └── google_adk/ -│ ├── __init__.py # ✅ 主入口 (GoogleAdkInstrumentor) -│ ├── version.py # ✅ 版本信息 -│ └── internal/ -│ ├── __init__.py -│ ├── _plugin.py # ✅ GoogleAdkObservabilityPlugin -│ ├── _extractors.py # ✅ AdkAttributeExtractors -│ ├── _metrics.py # ✅ AdkMetricsCollector -│ └── _utils.py # ✅ 工具函数 -├── tests/ -│ ├── __init__.py -│ ├── test_extractors.py # ✅ 属性提取测试 -│ └── test_metrics.py # ✅ Metrics 测试 -├── docs/ -│ ├── trace-metrics-comparison.md # ✅ 详细对比文档 -│ ├── migration-plan.md # ✅ 迁移计划 -│ └── MIGRATION_SUMMARY.md # ✅ 迁移总结(本文档) -├── pyproject.toml # ✅ 项目配置 -└── README.md # ✅ 项目文档 -``` - ---- - -## 🎉 迁移成果 - -### 代码质量 - -- ✅ **100% 符合 OTel GenAI 语义规范**(最新版本) -- ✅ **移除所有 ARMS SDK 依赖** -- ✅ **标准化所有属性命名** -- ✅ **简化指标系统**(12 → 2 个指标) -- ✅ **测试覆盖核心功能** - -### 兼容性 - -- ✅ **与 openai-v2 插件一致**的实现模式 -- ✅ **可贡献到 OTel 官方仓库** -- ✅ **支持标准 OTel 环境变量** -- ✅ **遵循 OTel Python SDK 规范** - -### 文档完整性 - -- ✅ **README.md** - 完整的使用文档 -- ✅ **trace-metrics-comparison.md** - 详细的差异对比 -- ✅ **migration-plan.md** - 执行计划 -- ✅ **MIGRATION_SUMMARY.md** - 迁移总结(本文档) - ---- - -## 🔍 验证清单 - -### 代码验证 - -- [x] 所有 `gen_ai.system` 改为 `gen_ai.provider.name` -- [x] 移除所有 `gen_ai.span.kind` 引用 -- [x] 移除 `gen_ai.framework` 属性 -- [x] Agent/Tool 属性使用 `gen_ai.` 前缀 -- [x] `session.id` 改为 `conversation.id` -- [x] `user.id` 改为 `enduser.id` -- [x] 移除所有 12 个 ARMS 指标 -- [x] 实现 2 个标准 OTel 指标 -- [x] 移除指标中的高基数属性 -- [x] 实现标准内容捕获机制 -- [x] 移除 ARMS SDK 依赖 - -### 文档验证 - -- [x] README 包含使用说明 -- [x] 对比文档详细记录差异 -- [x] 测试文件验证关键变更 -- [x] 环境变量文档完整 - ---- - -## 📊 统计数据 - -### 代码变更统计 - -| 类别 | 商业版本 | 开源版本 | 变化 | -|------|---------|---------|------| -| **核心文件** | 6 | 6 | ➡️ 0 | -| **测试文件** | 0 (待创建) | 2 | ➕ 2 | -| **文档文件** | 2 | 4 | ➕ 2 | -| **依赖项** | ARMS SDK | 仅 OTel SDK | ✅ 简化 | -| **代码行数** | ~2500 | ~2000 | ⬇️ 20% | -| **指标数量** | 12 | 2 | ⬇️ 83% | - -### 属性变更统计 - -| 类别 | 变更数量 | 类型 | -|------|---------|------| -| **改名** | 8 | `gen_ai.system`, `session.id`, etc. | -| **移除** | 7 | `gen_ai.span.kind`, `framework`, etc. | -| **新增前缀** | 6 | Agent/Tool 属性 | -| **复数化** | 1 | `finish_reason` → `finish_reasons` | - ---- - -## 🚀 后续工作 - -### 可选的增强 - -1. **首包延迟支持** (可选) - - 当前:已移除(标准客户端规范中无此指标) - - 选项:作为自定义扩展添加 - -2. **更多测试用例** - - 当前:基础测试已完成 - - 增强:集成测试、端到端测试 - -3. **性能优化** - - 当前:功能完整 - - 增强:减少内存分配、优化 JSON 序列化 - -4. **示例代码** - - 当前:README 中有基础示例 - - 增强:完整的 examples/ 目录 - -### 贡献到 OTel 社区 - -- [ ] 提交 PR 到 opentelemetry-python-contrib -- [ ] 注册到 PyPI -- [ ] 添加到 OTel Registry - ---- - -## 📝 注意事项 - -### 非向后兼容的变更 - -⚠️ **这是一个全新的实现,与商业版本 API 不兼容** - -- ❌ 不能直接替换商业版本 -- ✅ 需要更新导入语句 -- ✅ 需要更新环境变量 -- ✅ 需要更新依赖项 - -### 迁移建议 - -1. **测试环境先行**:在测试环境完成迁移验证 -2. **监控对比**:对比迁移前后的指标变化 -3. **逐步迁移**:分批次迁移生产环境 -4. **文档同步**:更新内部文档和运维手册 - ---- - -## 📧 联系方式 - -如有问题,请: - -- 📖 查阅 [README.md](../README.md) -- 🐛 提交 [Issue](https://github.com/your-org/loongsuite-python-agent/issues) -- 💬 参与 [Discussions](https://github.com/your-org/loongsuite-python-agent/discussions) - ---- - -**迁移完成日期**: 2025-10-21 -**迁移版本**: v0.1.0 -**基于规范**: OpenTelemetry GenAI Semantic Conventions (最新版本) - - diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/migration-plan.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/migration-plan.md deleted file mode 100644 index 16723d83..00000000 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/migration-plan.md +++ /dev/null @@ -1,220 +0,0 @@ -# Google ADK 插件迁移执行计划 - -## 项目概况 - -本文档描述如何将 Google ADK 插件从 ARMS 商业版本迁移到 LoongSuite 开源项目。 - -### 商业版本现状 -- **位置**:`aliyun-instrumentation-google-adk/` -- **命名空间**:`aliyun.instrumentation.google_adk` -- **架构**:基于 Google ADK Plugin 机制 -- **依赖特征**:依赖 ARMS SDK (`aliyun.sdk.extension.arms`) - -### 目标开源版本 -- **位置**:`instrumentation-genai/opentelemetry-instrumentation-google-adk/` -- **命名空间**:`opentelemetry.instrumentation.google_adk` -- **参考项目**:`opentelemetry-instrumentation-openai-v2` - ---- - -## 核心差异对照表 - -| 项目 | 商业版本 (ARMS) | 开源版本 (OTel) | -|------|----------------|-----------------| -| **命名空间** | `aliyun.instrumentation.google_adk` | `opentelemetry.instrumentation.google_adk` | -| **类名前缀** | `Aliyun*` | 标准OTel命名,无前缀 | -| **环境变量** | `ENABLE_GOOGLE_ADK_INSTRUMENTOR` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | -| **依赖项** | 依赖 ARMS SDK | 仅依赖标准 OTel SDK | -| **指标名称** | ARMS专有 + GenAI混合 (12个指标) | 标准 GenAI 语义规范 (2个指标) | -| **内容捕获** | ARMS SDK `process_content()` | 环境变量控制 | -| **包名** | `aliyun-instrumentation-google-adk` | `opentelemetry-instrumentation-google-adk` | - -> 📖 **详细差异分析**:请参阅 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 获取 Trace 和 Metrics 的完整对比分析。 - ---- - -## 详细迁移步骤 - -### 阶段一:项目结构创建(0.5天) - -创建目录结构: -``` -instrumentation-genai/opentelemetry-instrumentation-google-adk/ -├── src/ -│ └── opentelemetry/ -│ └── instrumentation/ -│ └── google_adk/ -│ ├── __init__.py -│ ├── package.py -│ ├── version.py -│ └── internal/ -│ ├── __init__.py -│ ├── _plugin.py -│ ├── _extractors.py -│ ├── _metrics.py -│ └── _utils.py -├── tests/ -├── pyproject.toml -├── README.md -├── LICENSE -└── CHANGELOG.md -``` - -### 阶段二:核心代码迁移(2天) - -> 📖 **重要**:迁移前请先阅读 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 了解详细差异。 - -#### 任务 2.1:迁移主入口 `__init__.py` -- 命名空间:`aliyun` → `opentelemetry` -- 类名:`AliyunGoogleAdkInstrumentor` → `GoogleAdkInstrumentor` -- 移除:`_ENABLE_GOOGLE_ADK_INSTRUMENTOR` 环境变量检查 -- 使用标准 OTel schema URL:`Schemas.V1_28_0.value` -- 移除 `_is_instrumentation_enabled()` 方法 - -#### 任务 2.2:迁移 `_plugin.py` -- 类名:`AliyunAdkObservabilityPlugin` → `GoogleAdkObservabilityPlugin` -- 移除:ARMS SDK 导入 -- 实现标准内容捕获机制(参考对比文档 1.3 节) -- 更新所有 `process_content()` 调用 -- 考虑使用 Event API 记录消息内容(推荐) - -#### 任务 2.3:迁移 `_extractors.py` -- 移除 ARMS SDK 导入 -- 使用本地 `_process_content()` 替换 -- 确保属性提取符合标准 GenAI 语义规范(参考对比文档 1.1 节) -- 关键修改: - - 移除 `gen_ai.model_name` 冗余属性 - - 修正 `finish_reason` 为 `finish_reasons` (数组) - - 移除冗余的 Tool 属性 - - 调整 Span 命名格式(参考对比文档 1.2 节) - -#### 任务 2.4:迁移 `_metrics.py` ⚠️ **最复杂部分** -- **完全重构**:参考 `openai-v2/instruments.py` 实现 -- 移除所有 ARMS 指标(12个 → 2个) -- 实现标准 OTel GenAI 指标: - - `gen_ai.client.operation.duration` (Histogram) - - `gen_ai.client.token.usage` (Histogram) -- 使用标准 GenAI 属性(参考对比文档 2.2 节) -- 移除 session/user 作为指标维度(避免高基数) -- 详细对比请参阅对比文档第 2 节 - -### 阶段三:测试迁移(1.5天) - -需要迁移的测试文件: -- ✅ `test_basic.py` -- ✅ `test_plugin.py` -- ✅ `test_extractors.py` -- ✅ `test_metrics.py`(需要大幅修改) -- ✅ `test_utils.py` -- ✅ `test_semantic_convention_compliance.py` -- ✅ `test_content_capture.py` -- ❌ `test_arms_compatibility.py`(移除) -- ✅ `test_trace_validation.py` - -### 阶段四:文档和配置(0.5天) - -创建完整的开源项目文档。 - -### 阶段五:验证和优化(1天) - -- 功能验证 -- 语义规范合规性检查 -- 代码清理 - ---- - -## 关键风险点 - -### 1. 指标系统重构 ⚠️ **高风险** - -**问题**:商业版本使用了双指标体系(ARMS + GenAI),共 12 个指标;开源版本只能用标准 GenAI 指标,仅 2 个。 - -**影响**: -- ❌ 失去:错误计数、慢调用计数、首包延迟等专有指标 -- ✅ 保留:操作耗时、Token 用量(通过标准 Histogram) -- ⚠️ 需确认:首包延迟是否有标准指标 - -**缓解措施**: -1. 参考 `openai-v2/instruments.py` 的标准实现 -2. 评估功能缺失的影响(大部分可通过 Histogram 聚合补偿) -3. 必要时考虑自定义扩展(如首包延迟) - -详细分析请参阅 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 第 2 节。 - -### 2. 内容捕获机制 - -**问题**:需要自己实现,基于环境变量控制,确保不泄露敏感信息。 - -**挑战**: -- ARMS SDK 的 `process_content()` 提供了自动截断和敏感信息过滤 -- 开源版本需要手动实现这些功能 - -**缓解措施**: -1. 实现 `_process_content()` 工具函数 -2. 支持 `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` 环境变量 -3. 支持 `OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH` 长度限制 -4. 考虑迁移到 Event API(OTel 推荐) - -详细实现请参阅 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 第 1.3 节。 - -### 3. Session/User 追踪 - -**问题**:需要确认这些是否符合标准 OTel 规范。 - -**待确认**: -- ❓ 标准 GenAI 规范是否定义了 `gen_ai.session.id` 和 `gen_ai.user.id`? -- ❓ 如果未定义,是否允许自定义扩展? -- ❓ 这些属性应该在 Trace 中还是 Metrics 中? - -**建议**: -1. 查阅最新 OTel GenAI 语义规范 v1.37.0 -2. Session/User 信息仅在 Trace 中记录,不作为 Metrics 维度(避免高基数) -3. 如果标准未定义,使用自定义命名空间(如 `google_adk.session.id`) - -详细讨论请参阅 [trace-metrics-comparison.md](./trace-metrics-comparison.md) 第 4.1 节。 - ---- - -## 时间估算 - -| 阶段 | 预计时间 | -|------|----------| -| 阶段一:项目结构创建 | 0.5天 | -| 阶段二:核心代码迁移 | 2天 | -| 阶段三:测试迁移 | 1.5天 | -| 阶段四:文档和配置 | 0.5天 | -| 阶段五:验证和优化 | 1天 | -| **总计** | **5.5天** | - ---- - -## 迁移检查清单 - -### 代码层面 -- [ ] 所有文件命名空间从 `aliyun` 改为 `opentelemetry` -- [ ] 所有类名移除 `Aliyun` 前缀 -- [ ] 移除所有 ARMS SDK 依赖和导入 -- [ ] 实现标准内容捕获机制 -- [ ] 指标完全符合 GenAI 规范 -- [ ] 移除所有 ARMS 专有环境变量 -- [ ] 移除所有 ARMS 专有属性和标签 - -### 测试层面 -- [ ] 所有测试导入路径更新 -- [ ] 移除 ARMS 专有测试 -- [ ] 更新指标验证逻辑 -- [ ] 更新环境变量测试 -- [ ] 所有测试通过 - -### 文档层面 -- [ ] README.md 完整 -- [ ] LICENSE 正确 -- [ ] CHANGELOG.md 创建 -- [ ] pyproject.toml 正确配置 -- [ ] 注释英文化 - -### 规范层面 -- [ ] Span 属性符合 GenAI 规范 -- [ ] Metric 名称符合 GenAI 规范 -- [ ] Span kind 正确映射 -- [ ] Schema URL 正确 \ No newline at end of file diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/trace-metrics-comparison.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/trace-metrics-comparison.md deleted file mode 100644 index 8e55279c..00000000 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/docs/trace-metrics-comparison.md +++ /dev/null @@ -1,674 +0,0 @@ -# Google ADK 插件 Trace & Metrics 差异对比分析 - -本文档详细对比商业版本(ARMS)和开源版本(OTel)在 Trace 和 Metrics 实现上的差异。 - -**基于 OTel GenAI Semantic Conventions(最新版本)** - ---- - -## 一、Trace 差异分析 - -### 1.1 Span 属性命名规范对比 - -| 属性类别 | 商业版本 (ARMS) | 开源版本 (OTel 最新) | 一致性 | 备注 | -|---------|----------------|-----------------|--------|------| -| **核心属性** | -| Operation Name | `gen_ai.operation.name` | `gen_ai.operation.name` | ✅ 一致 | chat/invoke_agent/execute_tool | -| Provider | `gen_ai.system` | `gen_ai.provider.name` | ❌ **名称变更** | **必须改为 provider.name** | -| Framework | `gen_ai.framework` | 无 | ❌ 非标准 | 需要去除 | -| **LLM 请求属性** | -| Model Name | `gen_ai.model_name` | 无 | ❌ **冗余,需移除** | 只保留 request.model | -| | `gen_ai.request.model` | `gen_ai.request.model` | ✅ 一致 | | -| Max Tokens | `gen_ai.request.max_tokens` | `gen_ai.request.max_tokens` | ✅ 一致 | | -| Temperature | `gen_ai.request.temperature` | `gen_ai.request.temperature` | ✅ 一致 | | -| Top P | `gen_ai.request.top_p` | `gen_ai.request.top_p` | ✅ 一致 | | -| Top K | `gen_ai.request.top_k` | `gen_ai.request.top_k` | ✅ 一致 | | -| Stream | ❌ `gen_ai.request.is_stream` | 无此属性 | ❌ 非标准 | 需要移除 | -| **LLM 响应属性** | -| Response Model | `gen_ai.response.model` | `gen_ai.response.model` | ✅ 一致 | | -| Finish Reason | `gen_ai.response.finish_reason` | `gen_ai.response.finish_reasons` | ❌ **单复数差异** | **必须改为复数数组** | -| Input Tokens | `gen_ai.usage.input_tokens` | `gen_ai.usage.input_tokens` | ✅ 一致 | | -| Output Tokens | `gen_ai.usage.output_tokens` | `gen_ai.usage.output_tokens` | ✅ 一致 | | -| Total Tokens | ❌ `gen_ai.usage.total_tokens` | 无 | ❌ 非标准 | 需要移除 | -| **消息内容** | -| Input Messages | `gen_ai.input.messages` | `gen_ai.input.messages` | ✅ **一致** | Opt-In 属性,需遵循 JSON Schema | -| Output Messages | `gen_ai.output.messages` | `gen_ai.output.messages` | ✅ **一致** | Opt-In 属性,需遵循 JSON Schema | -| System Instructions | `gen_ai.system_instructions` | `gen_ai.system_instructions` | ✅ 一致 | Opt-In 属性 | -| Tool Definitions | `gen_ai.tool.definitions` | `gen_ai.tool.definitions` | ✅ 一致 | Opt-In 属性 | -| Message Count | `gen_ai.input.message_count` | 无 | ❌ 非标准,移除 | 可从 messages 数组获取 | -| | `gen_ai.output.message_count` | 无 | ❌ 非标准,移除 | | -| **Session 追踪** | -| Session/Conversation ID | `gen_ai.session.id` | `gen_ai.conversation.id` | ⚠️ **名称不同** | **改为 conversation.id** | -| User ID | ❌ `gen_ai.user.id` | 无标准属性 | ❌ 非标准 | 考虑使用 `enduser.id` (标准) | -| **Agent 属性(invoke_agent spans)** | -| Agent Name | `agent.name` | `gen_ai.agent.name` | ⚠️ 缺少前缀 | 应改为 `gen_ai.agent.name` | -| Agent ID | 无 | `gen_ai.agent.id` | ❌ 缺失 | 尽可能采集,如果无法获取到(如框架中没有定义)则不采集 | -| Agent Description | `agent.description` | `gen_ai.agent.description` | ⚠️ 缺少前缀 | 应改为 `gen_ai.agent.description` | -| Data Source ID | 无 | `gen_ai.data_source.id` | ❌ 缺失 | RAG 场景需要,应尽可能采集 | -| **Tool 属性(execute_tool spans)** | -| Tool Name | `tool.name` / `gen_ai.tool.name` | `gen_ai.tool.name` | ⚠️ 缺少前缀 | 商业版有 `tool.name`,应统一为 `gen_ai.tool.name` | -| Tool Description | `tool.description` / `gen_ai.tool.description` | `gen_ai.tool.description` | ⚠️ 缺少前缀 | 同上,应统一为 `gen_ai.tool.description` | -| Tool Parameters | `tool.parameters` | `gen_ai.tool.call.arguments` | ❌ **属性名错误** | 应改为 `gen_ai.tool.call.arguments` | -| Tool Call ID | 无 | `gen_ai.tool.call.id` | ❌ 缺失 | 应尽可能采集,如果无法获取到(如框架中没有定义)则不采集 | -| Tool Type | 无 | `gen_ai.tool.type` | ❌ 缺失 | 默认为 function,应尽可能采集,如果无法获取到(如框架中没有定义)则不采集 | -| Tool Result | 无 | `gen_ai.tool.call.result` | ❌ 缺失 | 应尽可能采集,如果无法获取到(如框架中没有定义)则不采集 | -| **错误属性** | -| Error Type | `error.type` | `error.type` | ✅ 一致 | | -| Error Message | `error.message` | 无(非标准) | ⚠️ | OTel 推荐使用 span status | -| **ADK 框架专有属性** | -| App Name | `runner.app_name` | 无 | ❌ 非标准 | 考虑作为自定义扩展保留 | -| Invocation ID | `runner.invocation_id` | 无 | ❌ 非标准 | 考虑作为自定义扩展保留 | - -### 1.2 Span 命名规范对比 - -| Span 类型 | 商业版本 (ARMS) | OTel 标准命名 | 一致性 | 说明 | -|----------|----------------|---------------|--------|------| -| **LLM (Inference)** | `chat {model}` | `{operation_name} {request.model}` | ✅ 基本一致 | 如 `chat gpt-4` | -| **Agent (Invoke)** | `invoke_agent {agent_name}` | `invoke_agent {agent.name}` | ✅ 一致 | 如 `invoke_agent Math Tutor` | -| | | 或 `invoke_agent` (无名称时) | | | -| **Agent (Create)** | 无 | `create_agent {agent.name}` | ❌ 缺失 | 创建 agent 场景 | -| **Tool** | `execute_tool {tool_name}` | `execute_tool {tool.name}` | ✅ 一致 | 如 `execute_tool get_weather` | -| **Runner** | `invoke_agent {app_name}` | 同 Agent Invoke | ⚠️ 需调整 | Runner 视为顶级 Agent | - -**OTel 标准规范**: -- **LLM spans**: `{gen_ai.operation.name} {gen_ai.request.model}` - - 示例:`chat gpt-4`, `generate_content gemini-pro` -- **Agent invoke spans**: `invoke_agent {gen_ai.agent.name}` 或 `invoke_agent`(name 不可用时) -- **Agent create spans**: `create_agent {gen_ai.agent.name}` -- **Tool spans**: `execute_tool {gen_ai.tool.name}` - - 示例:`execute_tool get_weather`, `execute_tool search` - -### 1.3 内容捕获机制对比 - -| 特性 | 商业版本 (ARMS) | 开源版本 (OTel) | -|-----|----------------|-----------------| -| **实现方式** | ARMS SDK `process_content()` | 自实现 + 环境变量 | -| **控制变量** | `ENABLE_GOOGLE_ADK_INSTRUMENTOR` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | -| **长度限制** | ARMS SDK 内置 | `OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH` | -| **截断标记** | ARMS 自动处理 | 需自实现 `[TRUNCATED]` | -| **敏感信息** | ARMS SDK 处理 | 需自己实现过滤 | -| **存储位置** | Span attributes | Events (推荐) 或 Attributes | - -**商业版本实现**: -```python -from aliyun.sdk.extension.arms.utils.capture_content import process_content - -# 自动处理长度限制和敏感信息过滤 -content = process_content(raw_content) -span.set_attribute("gen_ai.input.messages", content) -``` - -**开源版本需要实现**: -```python -import os - -def _should_capture_content() -> bool: - return os.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "false").lower() == "true" - -def _get_max_length() -> Optional[int]: - limit = os.getenv("OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH") - return int(limit) if limit else None - -def _process_content(content: str) -> str: - if not _should_capture_content(): - return "" - - max_length = _get_max_length() - if max_length and len(content) > max_length: - return content[:max_length] + " [TRUNCATED]" - - return content - -# 推荐使用 Event API 而非 Attribute -event_logger.emit(Event( - name="gen_ai.content.prompt", - attributes={"content": _process_content(content)} -)) -``` - -### 1.4 Span Kind 和 Operation Name 对比 - -| ADK 组件 | 商业版本 | OTel 标准 | OTel SpanKind | 说明 | -|---------|---------|----------|---------------|------| -| **LLM 调用** | ❌ 使用 `gen_ai.span.kind` | ✅ `gen_ai.operation.name=chat` | `CLIENT` | **不使用 span.kind 属性** | -| **Runner** | ❌ `gen_ai.span.kind=AGENT` | ✅ `operation.name=invoke_agent` | `CLIENT` | **必须改用 operation.name** | -| **BaseAgent** | ❌ `gen_ai.span.kind=AGENT` | ✅ `operation.name=invoke_agent` | `CLIENT` | 同上 | -| **Tool** | ❌ `gen_ai.span.kind=TOOL` | ✅ `operation.name=execute_tool` | `INTERNAL` | 同上,规范建议 INTERNAL | - -**重要变更**: -- ❌ **`gen_ai.span.kind` 不是标准属性**,需要完全移除 -- ✅ 使用 `gen_ai.operation.name` 区分操作类型: - - `chat` - LLM 聊天 - - `generate_content` - 多模态内容生成 - - `invoke_agent` - 调用 Agent - - `create_agent` - 创建 Agent - - `execute_tool` - 执行工具 - - `embeddings` - 向量嵌入 - - `text_completion` - 文本补全(Legacy) - -- ✅ OTel `SpanKind` 的选择: - - `CLIENT` - 调用外部服务(LLM API, 远程 Agent)**推荐默认** - - `INTERNAL` - 本地处理(本地 Agent, 本地 Tool) - -**这是最大的变更点之一!** - -### 1.5 Tool 属性详细说明(重要补充) - -根据 OTel GenAI 规范的 "Execute tool span" 部分,标准定义了完整的 Tool 属性集: - -| 属性名称 | 类型 | 要求级别 | 描述 | 示例 | -|---------|------|---------|------|------| -| `gen_ai.operation.name` | string | **Required** | 必须为 `"execute_tool"` | `execute_tool` | -| `gen_ai.tool.name` | string | **Recommended** | 工具名称 | `get_weather`, `search` | -| `gen_ai.tool.description` | string | Recommended (if available) | 工具描述 | `Get weather information` | -| `gen_ai.tool.call.id` | string | Recommended (if available) | 工具调用唯一标识 | `call_mszuSIzqtI65i1wAUOE8w5H4` | -| `gen_ai.tool.type` | string | Recommended (if available) | 工具类型 | `function`, `extension`, `datastore` | -| `gen_ai.tool.call.arguments` | any | **Opt-In** | 传递给工具的参数 | `{"location": "Paris", "date": "2025-10-01"}` | -| `gen_ai.tool.call.result` | any | **Opt-In** | 工具返回的结果 | `{"temperature": 75, "conditions": "sunny"}` | -| `error.type` | string | Conditionally Required | 错误类型(如果有错误) | `timeout` | - -**商业版本 vs 开源版本对照**: - -```python -# ❌ 商业版本(错误的实现) -span.set_attribute("tool.name", "get_weather") # 缺少 gen_ai 前缀 -span.set_attribute("tool.description", "Get weather") # 缺少 gen_ai 前缀 -span.set_attribute("tool.parameters", json.dumps({...})) # 错误的属性名 -# 缺失: tool.call.id, tool.type, tool.call.result - -# ✅ 开源版本(正确的实现) -span.set_attribute("gen_ai.operation.name", "execute_tool") # Required -span.set_attribute("gen_ai.tool.name", "get_weather") # Recommended -span.set_attribute("gen_ai.tool.description", "Get weather") # Recommended -span.set_attribute("gen_ai.tool.call.id", "call_123") # Recommended -span.set_attribute("gen_ai.tool.type", "function") # Recommended -span.set_attribute("gen_ai.tool.call.arguments", {...}) # Opt-In (结构化) -span.set_attribute("gen_ai.tool.call.result", {...}) # Opt-In (结构化) -``` - -**关键差异**: -1. ✅ **前缀必须**: 所有属性都需要 `gen_ai.` 前缀 -2. ✅ **参数和结果**: 使用 `tool.call.arguments` 和 `tool.call.result`(而非 `tool.parameters`) -3. ✅ **新增属性**: `tool.call.id` 和 `tool.type` 是新增的标准属性 -4. ✅ **Span name**: 应为 `execute_tool {tool.name}` -5. ✅ **Span kind**: 应为 `INTERNAL`(不是 `CLIENT`) - ---- - -## 二、Metrics 差异分析 - -### 2.1 指标名称和类型对比 - -#### 标准 OTel GenAI Client Metrics(最新规范) - -| 指标名称 | 类型 | 单位 | 描述 | 必需属性 | 推荐属性 | -|---------|------|------|------|---------|---------| -| `gen_ai.client.operation.duration` | Histogram | `s` (秒) | 客户端操作耗时 | `gen_ai.operation.name`
`gen_ai.provider.name` | `gen_ai.request.model`
`gen_ai.response.model`
`server.address`
`server.port`
`error.type` (错误时) | -| `gen_ai.client.token.usage` | Histogram | `{token}` | Token 使用量 | 同上
`gen_ai.token.type` | 同上 | - -**标准规范要点**: -- ✅ **仅 2 个客户端指标**,使用 Histogram 类型 -- ✅ `gen_ai.provider.name` 是**必需属性**(不是 `system`) -- ✅ `gen_ai.token.type` 值为 `input` 或 `output` -- ✅ `error.type` 仅在错误时设置 -- ❌ **没有**单独的错误计数器、慢调用计数器等 - -#### 商业版本 ARMS 指标(当前实现)- **需要完全移除** - -| 指标名称 | 类型 | 状态 | 迁移方案 | -|---------|------|------|---------| -| **ARMS 专有指标** | | | | -| `calls_count` | Counter | ❌ **移除** | 用 `operation.duration` Histogram 替代 | -| `calls_duration_seconds` | Histogram | ❌ **移除** | 用标准 `operation.duration` 替代 | -| `call_error_count` | Counter | ❌ **移除** | 通过 `operation.duration` + `error.type` 维度查询 | -| `llm_usage_tokens` | Counter | ❌ **移除** | 用标准 `token.usage` Histogram 替代 | -| `llm_first_token_seconds` | Histogram | ⚠️ **可选保留** | 标准无此指标,见下方说明 | -| **自定义 GenAI 指标** | | | | -| `genai_calls_count` | Counter | ❌ **移除** | 同上 | -| `genai_calls_duration_seconds` | Histogram | ❌ **移除** | 同上 | -| `genai_calls_error_count` | Counter | ❌ **移除** | 同上 | -| `genai_calls_slow_count` | Counter | ❌ **移除** | 通过 Histogram 百分位聚合获得 | -| `genai_llm_first_token_seconds` | Histogram | ⚠️ **可选保留** | 同上 | -| `genai_llm_usage_tokens` | Counter | ❌ **移除** | 同上 | -| `genai_avg_first_token_seconds` | Histogram | ❌ **移除** | 由后端聚合计算 | - -**关键变化**: -- ❌ **移除双指标体系**:12 个指标 → 2 个标准指标 -- ❌ **移除所有 Counter**:改用 Histogram,由后端聚合 -- ❌ **移除显式错误/慢调用计数**:通过 Histogram + 维度查询获得 -- ⚠️ **首包延迟处理**:需要决策(见下方) - -### 2.2 指标维度(Labels/Attributes)对比 - -#### 标准 OTel GenAI Metrics 维度(必须遵循) - -```python -# operation.duration 和 token.usage 的必需属性 -{ - "gen_ai.operation.name": "chat", # Required: chat/invoke_agent/execute_tool 等 - "gen_ai.provider.name": "openai", # Required: 提供商标识 -} - -# 推荐属性(根据可用性添加) -{ - "gen_ai.request.model": "gpt-4", # Recommended: 请求的模型 - "gen_ai.response.model": "gpt-4-0613", # Recommended: 实际响应的模型 - "server.address": "api.openai.com", # Recommended: 服务器地址 - "server.port": 443, # Recommended (如果有 address) - "error.type": "TimeoutError", # Conditionally Required: 仅错误时 -} - -# token.usage 专有属性 -{ - "gen_ai.token.type": "input", # Required: "input" 或 "output" -} -``` - -#### 商业版本 ARMS Metrics 维度(**需要完全移除**) - -```python -{ - # ❌ ARMS 专有维度 - 全部移除 - "callType": "gen_ai", # 移除 - "callKind": "custom_entry", # 移除 - "rpcType": 2100, # 移除 - "rpc": "chat gpt-4", # 移除 - - # ❌ 错误的属性名 - 需要改名 - "modelName": "gpt-4", # → gen_ai.request.model - "spanKind": "LLM", # → gen_ai.operation.name - "usageType": "input", # → gen_ai.token.type - - # ❌ 不应出现在指标中的高基数属性 - "session_id": "...", # 移除(仅用于 trace) - "user_id": "...", # 移除(仅用于 trace) -} -``` - -**关键差异总结**: -1. ❌ **必须移除**所有 ARMS 专有维度:`callType`, `callKind`, `rpcType`, `rpc` -2. ❌ **必须改名**:`modelName` → `gen_ai.request.model`, `usageType` → `gen_ai.token.type` -3. ❌ **必须移除** `spanKind` 维度,改用 `gen_ai.operation.name` -4. ❌ **必须移除**高基数属性:`session_id`, `user_id`(这些仅用于 trace) -5. ✅ **必须添加** `gen_ai.provider.name`(新的必需属性) - -### 2.3 指标记录逻辑对比 - -#### 标准 OTel 实现(openai-v2) - -```python -# 1. 记录操作耗时 -instruments.operation_duration_histogram.record( - duration, - attributes={ - "gen_ai.operation.name": "chat", - "gen_ai.request.model": "gpt-4", - "gen_ai.response.model": "gpt-4-0613", - "gen_ai.system": "openai", - "error.type": error_type, # 仅在错误时 - } -) - -# 2. 记录 Token 用量(输入) -instruments.token_usage_histogram.record( - input_tokens, - attributes={ - # ... 同上 - "gen_ai.token.type": "input", - } -) - -# 3. 记录 Token 用量(输出) -instruments.token_usage_histogram.record( - output_tokens, - attributes={ - # ... 同上 - "gen_ai.token.type": "output", - } -) -``` - -**特点**: -- ✅ **简洁**:只记录 2 个指标,多次调用 -- ✅ **标准化**:完全符合 OTel 语义规范 -- ✅ **通过属性区分**:用 `error.type` 区分成功/失败,而非单独的错误计数器 - -#### 商业版本 ARMS 实现 - -```python -# 1. ARMS 指标(主要,用于控制台) -self.calls_count.add(1, attributes=arms_labels) -self.calls_duration_seconds.record(duration, attributes=arms_labels) -if is_error: - self.call_error_count.add(1, attributes=arms_labels) - -# 2. Token 用量(ARMS 格式) -if prompt_tokens > 0: - self.llm_usage_tokens.add(prompt_tokens, attributes={ - **arms_labels, - "usageType": "input" - }) -if completion_tokens > 0: - self.llm_usage_tokens.add(completion_tokens, attributes={ - **arms_labels, - "usageType": "output" - }) - -# 3. 首包延迟 -if first_token_time: - self.llm_first_token_seconds.record(first_token_time, attributes=arms_labels) - self.genai_avg_first_token_seconds.record(first_token_time, ...) - -# 4. GenAI 兼容指标(辅助) -self.genai_calls_count.add(1, genai_labels) -self.genai_calls_duration.record(duration, genai_labels) -if is_error: - self.genai_calls_error_count.add(1, genai_labels) -if is_slow: - self.genai_calls_slow_count.add(1, genai_labels) -# ... 更多 -``` - -**特点**: -- ❌ **复杂**:双指标体系,每次调用记录多个指标 -- ❌ **冗余**:相同信息记录两次(ARMS + GenAI) -- ⚠️ **慢调用**:自定义 `genai_calls_slow_count`,标准 OTel 应通过 Histogram 聚合 -- ⚠️ **首包延迟**:两个指标,标准可能只需一个 - -### 2.4 首包延迟(Time to First Token)处理 - -#### 标准 OTel 规范 - -查阅最新的 OTel GenAI Metrics 规范,发现: -- ❌ **客户端指标中没有首包延迟** -- ✅ **服务端指标有** `gen_ai.server.time_to_first_token` (Histogram) - - 用于模型服务器端的监控 - - 客户端插件通常不实现服务端指标 - -#### 商业版本实现 - -```python -# 当前实现:2 个首包延迟指标 -self.llm_first_token_seconds.record(first_token_time, ...) # ARMS 指标 -self.genai_llm_first_token_seconds.record(first_token_time, ...) # GenAI 指标 -self.genai_avg_first_token_seconds.record(first_token_time, ...) # 平均指标 -``` - -#### 迁移决策 - -**选项 1:移除首包延迟指标(推荐)** -- ✅ 符合标准 OTel 客户端规范 -- ✅ 减少指标数量 -- ❌ 失去首包延迟可见性 - -**选项 2:保留为自定义扩展** -```python -# 自定义指标(非标准) -self.gen_ai_client_time_to_first_token = meter.create_histogram( - name="gen_ai.client.time_to_first_token", # 自定义名称 - description="Time to first token for streaming responses", - unit="s" -) -``` -- ✅ 保留首包延迟可见性 -- ⚠️ 非标准,需要明确文档说明 -- ⚠️ 需要评估是否真正需要 - -**建议**: -- 对于开源版本,推荐**选项 1**(移除) -- Google ADK 目前没有提供原生的首包延迟数据 -- 如果确实需要,可以在 span 中记录为事件或属性 - -### 2.5 Agent/Tool 指标处理 - -#### 商业版本问题 - -```python -# ❌ 错误的实现 -record_agent_call( - span_kind="AGENT", # 使用非标准的 span_kind - agent_name="my_agent", - session_id="...", # 高基数属性 - user_id="..." # 高基数属性 -) -``` - -#### 标准 OTel 实现 - -```python -# ✅ 正确的实现 -instruments.operation_duration_histogram.record( - duration, - attributes={ - "gen_ai.operation.name": "invoke_agent", - "gen_ai.provider.name": "google_adk", - "gen_ai.request.model": agent_name, # Agent 名称作为 model - # 或者 - # "gen_ai.agent.name": agent_name, # 如果适用 - } -) - -# Token 使用量(如果有) -instruments.token_usage_histogram.record( - token_count, - attributes={ - "gen_ai.operation.name": "invoke_agent", - "gen_ai.provider.name": "google_adk", - "gen_ai.token.type": "input", # 或 "output" - "gen_ai.request.model": agent_name, - } -) -``` - -**关键点**: -1. ✅ 统一使用 2 个标准指标 -2. ✅ 通过 `gen_ai.operation.name` 区分操作类型 -3. ❌ 完全移除 session_id/user_id(仅在 trace 中) -4. ✅ Agent/Tool 名称可以放在 `gen_ai.request.model` 或 `gen_ai.agent.name` - ---- - -## 三、迁移行动计划 - -### 3.1 Trace 迁移要点(基于最新规范) - -| 任务 | 优先级 | 复杂度 | 说明 | -|------|--------|--------|------| -| **🔥 核心属性变更** | -| ❌ `gen_ai.system` → ✅ `gen_ai.provider.name` | 🔴 **最高** | 🟢 低 | **所有地方都要改** | -| ❌ 移除 `gen_ai.span.kind` | 🔴 **最高** | 🟡 中 | **完全移除,改用 operation.name** | -| ❌ 移除 `gen_ai.framework` | 🔴 高 | 🟢 低 | 非标准属性 | -| **属性名称标准化** | -| 移除 `gen_ai.model_name` 冗余 | 🔴 高 | 🟢 低 | 只保留 `gen_ai.request.model` | -| 修正 `finish_reason` → `finish_reasons` | 🔴 高 | 🟢 低 | 必须改为复数数组 | -| `session.id` → `conversation.id` | 🔴 高 | 🟢 低 | 标准属性名称 | -| 考虑 `user.id` → `enduser.id` | 🟡 中 | 🟢 低 | 使用标准用户ID属性 | -| **Agent 属性标准化** | -| `agent.name` → `gen_ai.agent.name` | 🔴 高 | 🟢 低 | 添加 gen_ai 前缀 | -| `agent.description` → `gen_ai.agent.description` | 🔴 高 | 🟢 低 | 同上 | -| 添加 `gen_ai.agent.id` | 🟡 中 | 🟢 低 | 新的标准属性 | -| **Tool 属性标准化** | -| `tool.name` → `gen_ai.tool.name` | 🔴 高 | 🟢 低 | 添加 gen_ai 前缀 | -| `tool.description` → `gen_ai.tool.description` | 🔴 高 | 🟢 低 | 同上 | -| `tool.parameters` → `gen_ai.tool.call.arguments` | 🔴 高 | 🟢 低 | 属性名变更 (Opt-In) | -| 添加 `gen_ai.tool.call.id` | 🟡 中 | 🟢 低 | 新的 Recommended 属性 | -| 添加 `gen_ai.tool.type` | 🟡 中 | 🟢 低 | 新的 Recommended 属性 | -| 添加 `gen_ai.tool.call.result` | 🟡 中 | 🟢 低 | 新的 Opt-In 属性 | -| **内容捕获机制** | -| 实现 `_process_content()` | 🔴 高 | 🟡 中 | 替换 ARMS SDK | -| 遵循 JSON Schema | 🔴 高 | 🟡 中 | input/output messages 格式 | -| **ADK 专有属性处理** | -| `runner.app_name` / `invocation_id` | 🟡 中 | 🟢 低 | 考虑保留为自定义扩展 | - -### 3.2 Metrics 迁移要点(最新规范) - -| 任务 | 优先级 | 复杂度 | 说明 | -|------|--------|--------|------| -| **🔥 完全重构指标系统** | -| ❌ 移除所有 ARMS 指标(5个) | 🔴 **最高** | 🟡 中 | 移除 `calls_count`, `llm_usage_tokens` 等 | -| ❌ 移除所有自定义 GenAI 指标(7个) | 🔴 **最高** | 🟡 中 | 移除 `genai_calls_count` 等 | -| ✅ 实现标准 2 个指标 | 🔴 **最高** | 🟠 高 | 参考 `openai-v2/instruments.py` | -| **✅ 标准指标实现** | -| `gen_ai.client.operation.duration` | 🔴 **最高** | 🟠 高 | Histogram, 单位=秒 | -| `gen_ai.client.token.usage` | 🔴 **最高** | 🟠 高 | Histogram, 单位=token | -| **🔥 维度完全重构** | -| ❌ 移除所有 ARMS 维度 | 🔴 **最高** | 🟡 中 | `callType`, `callKind`, `rpcType`, `rpc` | -| ❌ `spanKind` → ✅ `operation.name` | 🔴 **最高** | 🟡 中 | 概念完全不同 | -| ❌ `modelName` → ✅ `request.model` | 🔴 **最高** | 🟢 低 | 属性名变更 | -| ❌ `usageType` → ✅ `token.type` | 🔴 **最高** | 🟢 低 | 属性名变更 | -| ✅ 添加 `provider.name`(必需) | 🔴 **最高** | 🟢 低 | 新的必需属性 | -| ❌ 移除 `session_id`/`user_id` | 🔴 高 | 🟢 低 | 高基数,仅用于 trace | -| **功能调整** | -| 移除错误计数器 | 🔴 高 | 🟢 低 | 用 `error.type` 维度查询 | -| 移除慢调用计数器 | 🔴 高 | 🟢 低 | 通过 Histogram 百分位聚合 | -| 首包延迟处理 | 🟡 中 | 🟡 中 | 选项1:移除 或 选项2:自定义 | - -### 3.3 测试迁移要点 - -| 测试类型 | 商业版本 | 开源版本 | 迁移动作 | -|---------|---------|----------|---------| -| **保留并修改** | -| 基础功能测试 | `test_basic.py` | ✅ 保留 | 更新导入和类名 | -| Plugin 测试 | `test_plugin.py` | ✅ 保留 | 更新环境变量测试 | -| Extractor 测试 | `test_extractors.py` | ✅ 保留 | 验证属性名称 | -| 工具函数测试 | `test_utils.py` | ✅ 保留 | 测试新的内容捕获 | -| Trace 验证 | `test_trace_validation.py` | ✅ 保留 | 更新属性检查 | -| 语义规范测试 | `test_semantic_convention_compliance.py` | ✅ 保留 | 更新为 OTel 规范 | -| **大幅修改** | -| 指标测试 | `test_metrics.py` | ✅ 保留 | **完全重写** | -| 内容捕获测试 | `test_content_capture.py` | ✅ 保留 | 更新环境变量 | -| **移除** | -| ARMS 兼容测试 | `test_arms_compatibility.py` | ❌ 移除 | ARMS 专有 | -| Session/User 测试 | `test_session_user_tracking.py` | ⚠️ 可选 | 如果标准支持则保留 | - ---- - -## 四、关键决策点(已基于最新规范确认) - -### 4.1 已确认的标准规范(基于最新版本) - -1. **✅ Session 追踪** - - ✅ 标准属性:`gen_ai.conversation.id` - - ✅ 用途:存储和关联对话中的消息 - - ✅ 仅用于 trace,不用于 metrics - -2. **⚠️ User 追踪** - - ❌ `gen_ai.user.id` 不是标准属性 - - ✅ 建议使用:`enduser.id` (标准 OTel 属性) - - ✅ 仅用于 trace,不用于 metrics - -3. **✅ Agent/Tool Operation Name** - - ✅ Agent invoke: `gen_ai.operation.name = "invoke_agent"` - - ✅ Agent create: `gen_ai.operation.name = "create_agent"` - - ✅ Tool execute: `gen_ai.operation.name = "execute_tool"` - - ✅ LLM chat: `gen_ai.operation.name = "chat"` - -4. **❌ Span Kind 属性不存在** - - ❌ `gen_ai.span.kind` 不是标准属性 - - ✅ 使用 `gen_ai.operation.name` 区分类型 - - ✅ 使用 OTel `SpanKind` (CLIENT/INTERNAL) - -5. **✅ Provider Name(重要变更)** - - ❌ 旧属性:`gen_ai.system` - - ✅ 新属性:`gen_ai.provider.name` - - ✅ 这是必需属性 - -6. **⚠️ 首包延迟(Time to First Token)** - - ❌ 客户端规范中没有此指标 - - ✅ 服务端有 `gen_ai.server.time_to_first_token` - - 📝 **决策**:开源版本建议移除,或作为自定义扩展 - -### 4.2 可选的自定义扩展 - -如果标准规范未覆盖以下功能,考虑自定义扩展: - -1. **首包延迟指标** (如果标准未定义) - ```python - gen_ai.client.time_to_first_token (Histogram) - ``` - -2. **ADK 专有属性** (如果确实有价值) - ```python - google_adk.runner.app_name - google_adk.runner.invocation_id - ``` - -3. **Session 追踪** (如果标准未定义) - ```python - session.id - user.id - ``` - -**原则**: -- ✅ 优先使用标准规范 -- ✅ 必要时可以扩展,但需明确标注为非标准 -- ❌ 避免与标准规范冲突 - ---- - -## 五、总结 - -### 5.1 主要差异总结(最新规范对比) - -| 维度 | 商业版本特点 | 开源版本目标 | 迁移难度 | 关键变更 | -|------|------------|------------|---------|---------| -| **Trace 核心** | ❌ 使用 `gen_ai.system`
❌ 使用 `gen_ai.span.kind` | ✅ 使用 `gen_ai.provider.name`
✅ 使用 `gen_ai.operation.name` | 🟠 **高** | **概念完全变更** | -| **Trace 属性** | 部分冗余,ARMS 专有 | 完全符合最新 OTel 标准 | 🟡 中等 | 多处属性名变更 | -| **Metrics** | 12 个指标,双体系 | 2 个标准指标 | 🔴 **很高** | **完全重构** | -| **Metrics 维度** | ARMS 专有维度多 | 标准 GenAI 属性 | 🔴 **很高** | **所有维度都要改** | -| **内容捕获** | ARMS SDK 自动 | 遵循 JSON Schema | 🟡 中等 | 需自实现 | -| **测试** | ARMS 专有测试多 | 标准 OTel 测试 | 🟡 中等 | 指标测试需重写 | - -**最关键的 3 个变更**: -1. 🔥 `gen_ai.span.kind` → `gen_ai.operation.name`(概念变更) -2. 🔥 `gen_ai.system` → `gen_ai.provider.name`(属性改名) -3. 🔥 12 个指标 → 2 个指标,所有维度重构(完全重构) - -### 5.2 迁移风险评估 - -| 风险点 | 严重程度 | 缓解措施 | -|--------|---------|---------| -| **Metrics 完全重构** | 🔴 高 | 参考 openai-v2 实现,分步验证 | -| **标准规范不明确** | 🟡 中 | 查阅最新规范,必要时提问社区 | -| **功能缺失** | 🟡 中 | 评估是否真正需要,考虑自定义扩展 | -| **测试覆盖不足** | 🟡 中 | 完善语义规范合规性测试 | - -### 5.3 迁移工作量评估(基于最新规范) - -| 阶段 | 工作量(人日) | 复杂度 | 说明 | -|------|--------------|--------|------| -| **Phase 1: Trace 核心变更** | 2-3 | 🟠 高 | `gen_ai.system` → `provider.name`
`span.kind` → `operation.name` | -| **Phase 2: Trace 属性标准化** | 2-3 | 🟡 中 | Agent/Tool 属性、session/user 等 | -| **Phase 3: 内容捕获机制** | 2-3 | 🟡 中 | 实现 `_process_content()`
JSON Schema 遵循 | -| **Phase 4: Metrics 完全重构** | 5-7 | 🔴 很高 | 移除 12 个指标
实现 2 个标准指标
重构所有维度 | -| **Phase 5: 测试重写** | 4-6 | 🟠 高 | Metrics 测试完全重写
Trace 测试更新 | -| **Phase 6: 文档和示例** | 1-2 | 🟢 低 | README、迁移指南 | -| **总计** | **16-24 人日** | | 约 **3.5-5 周** | - -**关键里程碑**: -- Week 1: Trace 核心变更完成 -- Week 2-3: Metrics 完全重构 -- Week 4: 测试和文档 -- Week 5: 验证和优化(可选) - -**最高风险阶段**:Phase 4 (Metrics 重构) - -### 5.4 预期收益 - -1. ✅ **标准化**:完全符合 OTel GenAI 语义规范(最新版本) -2. ✅ **简化**:指标从 12 个减少到 2 个,大幅降低维护成本 -3. ✅ **可移植**:可贡献到 OTel 官方仓库 -4. ✅ **兼容性**:与其他 OTel GenAI 插件(openai-v2 等)完全一致 -5. ✅ **社区支持**:获得 OTel 社区的长期支持和演进 -6. ✅ **正确性**:基于最新规范,避免未来需要再次迁移 - ---- - -**最后更新**:2025-10-21 -**基于规范**:OTel GenAI Semantic Conventions (最新版本) -**参考文档**: -- `semantic-convention-genai/gen-ai-spans.md` -- `semantic-convention-genai/gen-ai-metrics.md` -- `semantic-convention-genai/gen-ai-agent-spans.md` -- `semantic-convention-genai/gen-ai-events.md` - diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app.py deleted file mode 100644 index 18df0b31..00000000 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -Google ADK Demo Application -演示 Agent、Tool、LLM 的集成使用 -""" -from google.adk.agents import Agent -from google.adk.tools import FunctionTool -from google.adk.runners import Runner -from datetime import datetime -import json - - -# 定义工具函数 -def get_current_time() -> str: - """获取当前时间""" - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - -def calculate(expression: str) -> str: - """ - 计算数学表达式 - - Args: - expression: 数学表达式,例如 "2 + 3" - """ - try: - result = eval(expression) - return f"计算结果:{result}" - except Exception as e: - return f"计算错误:{str(e)}" - - -# 创建 Tools -time_tool = FunctionTool( - name="get_current_time", - description="获取当前时间", - func=get_current_time -) - -calculator_tool = FunctionTool( - name="calculate", - description="计算数学表达式,支持加减乘除等基本运算", - func=calculate -) - -# 创建 Agent -math_assistant = Agent( - name="math_assistant", - description="一个能够执行数学计算和查询时间的智能助手", - tools=[time_tool, calculator_tool], - model="gemini-1.5-flash", # 或使用其他支持的模型 - instruction="你是一个专业的数学助手,可以帮助用户进行计算和查询时间。" -) - -# 创建 Runner -runner = Runner(app_name="math_assistant_demo", agent=math_assistant) - - -def main(): - """主函数""" - print("Google ADK Demo - Math Assistant") - print("=" * 50) - - # 测试场景 1:计算 - print("\n场景 1:数学计算") - result1 = runner.run("帮我计算 (125 + 375) * 2 的结果") - print(f"用户:帮我计算 (125 + 375) * 2 的结果") - print(f"助手:{result1}") - - # 测试场景 2:查询时间 - print("\n场景 2:查询时间") - result2 = runner.run("现在几点了?") - print(f"用户:现在几点了?") - print(f"助手:{result2}") - - # 测试场景 3:组合使用 - print("\n场景 3:组合使用") - result3 = runner.run("现在几点了?顺便帮我算一下 100 / 4") - print(f"用户:现在几点了?顺便帮我算一下 100 / 4") - print(f"助手:{result3}") - - print("\n" + "=" * 50) - print("Demo 完成") - - -if __name__ == "__main__": - main() - diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app_service.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app_service.py deleted file mode 100644 index 36e083ec..00000000 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/adk_app_service.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -Google ADK + FastAPI Service -将 Google ADK Agent 封装为 RESTful API 服务 -""" -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from google.adk.agents import Agent -from google.adk.tools import FunctionTool -from google.adk.runners import Runner -import uvicorn -from datetime import datetime - - -# 定义请求和响应模型 -class ChatRequest(BaseModel): - message: str - session_id: str = None - user_id: str = None - - -class ChatResponse(BaseModel): - response: str - session_id: str - token_usage: dict = None - - -# 创建 FastAPI 应用 -app = FastAPI(title="Google ADK API Service") - - -# 定义工具 -def get_weather(city: str) -> str: - """获取城市天气(模拟)""" - # 实际应用中这里应该调用真实的天气API - return f"{city}的天气:晴,温度25°C" - - -def search_knowledge(query: str) -> str: - """搜索知识库(模拟)""" - # 实际应用中这里应该连接真实的知识库 - return f"关于'{query}'的知识:这是模拟的知识库返回结果" - - -# 创建 Tools -weather_tool = FunctionTool( - name="get_weather", - description="获取指定城市的天气信息", - func=get_weather -) - -knowledge_tool = FunctionTool( - name="search_knowledge", - description="搜索内部知识库", - func=search_knowledge -) - -# 创建 Agent -assistant_agent = Agent( - name="customer_service_agent", - description="智能客服助手,可以查询天气和搜索知识库", - tools=[weather_tool, knowledge_tool], - model="gemini-1.5-flash", - system_instruction="你是一个专业的客服助手,态度友好,回答准确。" -) - -# 创建 Runner -runner = Runner(agent=assistant_agent) - - -# API 端点 -@app.get("/") -def root(): - """健康检查""" - return { - "service": "Google ADK API Service", - "status": "running", - "timestamp": datetime.now().isoformat() - } - - -@app.post("/chat", response_model=ChatResponse) -def chat(request: ChatRequest): - """ - 处理聊天请求 - - Args: - request: 包含用户消息和会话信息的请求 - - Returns: - ChatResponse: 包含 Agent 响应的结果 - """ - try: - # 执行 Agent - response = runner.run( - request.message, - session_id=request.session_id, - user_id=request.user_id - ) - - return ChatResponse( - response=response, - session_id=request.session_id or "default", - token_usage={"note": "Token usage info would be here"} - ) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/health") -def health(): - """健康检查端点""" - return {"status": "healthy"} - - -if __name__ == "__main__": - # 启动服务 - uvicorn.run( - app, - host="0.0.0.0", - port=8000, - log_level="info" - ) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/simple_demo.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/simple_demo.py deleted file mode 100644 index cfe42c99..00000000 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/simple_demo.py +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env python3 -""" -Google ADK 工具使用精简示例 -展示如何在 ADK Agent 中使用工具函数 -""" - -import os -import sys -import asyncio -import math -import random -from datetime import datetime -from typing import List, Dict, Any - -# ==================== 工具函数定义 ==================== - -def get_current_time() -> str: - """获取当前时间""" - return f"当前时间是: {datetime.now().strftime('%Y年%m月%d日 %H:%M:%S')}" - -def calculate_math(expression: str) -> str: - """数学计算工具""" - 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: - """掷骰子""" - if sides < 2: - sides = 6 - return random.randint(1, sides) - -def check_prime_numbers(numbers: List[int]) -> Dict[str, Any]: - """检查质数""" - 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 - - primes = [num for num in numbers if is_prime(num)] - non_primes = [num for num in numbers if not is_prime(num)] - - return { - "primes": primes, - "non_primes": non_primes, - "summary": f"质数: {primes}, 非质数: {non_primes}" - } - -def get_weather_info(city: str) -> str: - """获取天气信息(模拟)""" - weather_data = { - "北京": "晴朗,温度 15°C", - "上海": "多云,温度 18°C", - "深圳": "小雨,温度 25°C", - "杭州": "阴天,温度 20°C" - } - weather = weather_data.get(city, f"{city}的天气信息暂时无法获取") - return f"{city}的天气:{weather}" - -# ==================== ADK Agent 设置 ==================== - -async def create_agent(): - """创建带工具的 ADK Agent""" - from google.adk.agents import LlmAgent - from google.adk.models.lite_llm import LiteLlm - from google.adk.tools import FunctionTool - - # 检查环境变量 - api_key = os.getenv('DASHSCOPE_API_KEY') - if not api_key: - print("❌ 请设置 DASHSCOPE_API_KEY 环境变量") - print(" export DASHSCOPE_API_KEY='your-api-key'") - sys.exit(1) - - # 创建模型 - model = LiteLlm( - model="dashscope/qwen-plus", - api_key=api_key, - temperature=0.7, - max_tokens=1000, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" - ) - - # 创建工具 - tools = [ - FunctionTool(func=get_current_time), - FunctionTool(func=calculate_math), - FunctionTool(func=roll_dice), - FunctionTool(func=check_prime_numbers), - FunctionTool(func=get_weather_info) - ] - - # 创建 Agent - agent = LlmAgent( - name="simple_assistant", - model=model, - instruction="""你是一个智能助手,可以使用多种工具帮助用户。 -可用工具: -1. get_current_time - 获取当前时间 -2. calculate_math - 数学计算 -3. roll_dice - 掷骰子 -4. check_prime_numbers - 检查质数 -5. get_weather_info - 获取天气 - -用中文友好地与用户交流,根据需要调用工具。""", - description="一个简单的工具助手", - tools=tools - ) - - return agent - -async def run_conversation(user_input: str) -> str: - """运行对话并返回回复""" - from google.adk.runners import Runner - from google.adk.sessions.in_memory_session_service import InMemorySessionService - from google.genai import types - - # 初始化服务 - session_service = InMemorySessionService() - agent = await create_agent() - runner = Runner( - app_name="simple_demo", - agent=agent, - session_service=session_service - ) - - # 创建会话 - session = await session_service.create_session( - app_name="simple_demo", - user_id="demo_user", - session_id=f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - ) - - # 创建用户消息 - user_message = types.Content( - role="user", - parts=[types.Part(text=user_input)] - ) - - # 运行对话并收集事件 - events = [] - async for event in runner.run_async( - user_id="demo_user", - session_id=session.id, - new_message=user_message - ): - events.append(event) - - # 提取回复文本 - for event in events: - if hasattr(event, 'content') and event.content: - if hasattr(event.content, 'parts') and event.content.parts: - text_parts = [part.text for part in event.content.parts if hasattr(part, 'text') and part.text] - if text_parts: - return ''.join(text_parts) - - return "未收到有效回复" - -# ==================== 主程序 ==================== - -async def main(): - """主函数""" - print("🚀 Google ADK 工具使用精简示例") - print("=" * 50) - - # 测试用例 - test_cases = [ - "现在几点了?", - "计算 123 乘以 456", - "掷一个六面骰子", - "检查 17, 25, 29 是否为质数", - "北京的天气怎么样?" - ] - - for i, user_input in enumerate(test_cases, 1): - print(f"\n💬 测试 {i}: {user_input}") - print("-" * 40) - - try: - response = await run_conversation(user_input) - print(f"🤖 回复: {response}") - except Exception as e: - print(f"❌ 错误: {e}") - - # 避免请求过快 - if i < len(test_cases): - await asyncio.sleep(1) - - print("\n✅ 所有测试完成") - -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - print("\n👋 程序已停止") - except Exception as e: - print(f"❌ 运行失败: {e}") - import traceback - traceback.print_exc() - - From 078712a86b88446db47dfce2fc63a860b35ba78b Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Thu, 27 Nov 2025 15:59:00 +0800 Subject: [PATCH 08/11] feat: Enhance Google ADK instrumentation with global plugin support - Introduced a global instance of GoogleAdkObservabilityPlugin to facilitate both manual and auto instrumentation. - Updated the GoogleAdkInstrumentor to utilize the global plugin, ensuring consistent observability across multiple instances. - Added a wrapper function for Runner initialization to automatically inject the observability plugin. - Improved documentation for usage and functionality of the instrumentation methods. This update streamlines the instrumentation process and enhances compatibility with various usage scenarios. Change-Id: I8a7934626fd8e8deac1f595da66174b6ca8dd7bb Co-developed-by: Cursor --- .../instrumentation/google_adk/__init__.py | 167 ++++++++++++------ 1 file changed, 115 insertions(+), 52 deletions(-) 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 index 398c1037..ab7651af 100644 --- 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 @@ -5,13 +5,16 @@ applications, following the OpenTelemetry GenAI semantic conventions. 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 +from typing import Collection, Optional from opentelemetry import trace as trace_api, metrics as metrics_api from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -24,6 +27,86 @@ _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 + + +def _create_plugin_if_needed(tracer_provider=None, meter_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 + + Returns: + GoogleAdkObservabilityPlugin instance + """ + global _global_plugin + + if _global_plugin is None: + # Get tracer and meter + tracer = trace_api.get_tracer( + __name__, + __version__, + tracer_provider, + schema_url=Schemas.V1_28_0.value, + ) + + meter = metrics_api.get_meter( + __name__, + __version__, + meter_provider, + schema_url=Schemas.V1_28_0.value, + ) + + _global_plugin = GoogleAdkObservabilityPlugin(tracer, meter) + _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): """ @@ -31,13 +114,28 @@ class GoogleAdkInstrumentor(BaseInstrumentor): This instrumentor automatically injects observability into Google ADK applications following OpenTelemetry 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__() - self._plugin = None - self._original_plugins = None + + @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]: """ @@ -52,6 +150,10 @@ 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 @@ -63,34 +165,19 @@ def _instrument(self, **kwargs): except ImportError: _logger.warning("google-adk not found, instrumentation will not be applied") return - + tracer_provider = kwargs.get("tracer_provider") meter_provider = kwargs.get("meter_provider") - # Get tracer and meter - tracer = trace_api.get_tracer( - __name__, - __version__, - tracer_provider, - schema_url=Schemas.V1_28_0.value, - ) - - meter = metrics_api.get_meter( - __name__, - __version__, - meter_provider, - schema_url=Schemas.V1_28_0.value, - ) - - # Create and store the plugin instance - self._plugin = GoogleAdkObservabilityPlugin(tracer, meter) + # Create or get the global plugin instance + _create_plugin_if_needed(tracer_provider, meter_provider) # Wrap the Runner initialization to auto-inject our plugin try: wrap_function_wrapper( "google.adk.runners", "Runner.__init__", - self._runner_init_wrapper + _runner_init_wrapper # Use module-level function ) _logger.info("Google ADK instrumentation enabled") except Exception as e: @@ -103,43 +190,19 @@ def _uninstrument(self, **kwargs): Args: **kwargs: Optional keyword arguments """ + global _global_plugin + try: # Unwrap the Runner initialization from google.adk.runners import Runner unwrap(Runner, "__init__") - self._plugin = None + # Clear the global plugin + _global_plugin = None + _logger.info("Google ADK instrumentation disabled") except Exception as e: _logger.exception(f"Failed to uninstrument Google ADK: {e}") - def _runner_init_wrapper(self, wrapped, instance, args, kwargs): - """ - Wrapper for Runner.__init__ to auto-inject the observability plugin. - - Args: - wrapped: Original wrapped function - instance: Runner instance - args: Positional arguments - kwargs: Keyword arguments - - Returns: - Result of the original function - """ - # 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 self._plugin and self._plugin not in plugins: - plugins.append(self._plugin) - kwargs['plugins'] = plugins - _logger.debug("Injected OpenTelemetry observability plugin into Runner") - - # Call the original __init__ - return wrapped(*args, **kwargs) - __all__ = ["GoogleAdkInstrumentor"] - From b76aab9469f018d48f7723d1c933706a465138db Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Thu, 27 Nov 2025 22:04:53 +0800 Subject: [PATCH 09/11] doc: Updated CHANGELOG to reflect the addition of Google ADK support. Change-Id: [insert-change-id-here] Change-Id: Ibe84196785ff3c9869feaa5de1ff60582aa2eea3 Co-developed-by: Cursor --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d41798b..7f4e33dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > Use [this search for a list of all CHANGELOG.md files in this repo](https://github.com/search?q=repo%3Aalibaba%2Floongsuite-python-agent+path%3A**%2FCHANGELOG.md&type=code). ## Unreleased + +### Added + +- **loongsuite-instrumentation-google-adk**: Add initial support for Google Agent Development Kit (ADK) #35 From 58c6c3fc573abc1724e167571930395e7aa87c1d Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Thu, 27 Nov 2025 22:16:57 +0800 Subject: [PATCH 10/11] refactor: Clean up and organize imports in Google ADK instrumentation files - Reordered and grouped import statements for better readability in main.py and tools.py. - Updated import statements to use consistent quotation styles. - Removed unnecessary blank lines and improved formatting for clarity. - Enhanced the organization of code in internal modules to align with best practices. This refactoring improves code maintainability and readability across the Google ADK instrumentation files. Change-Id: I3e9723b4870ea0f062a044ea7d2c6c1e7bee39da Co-developed-by: Cursor --- .../examples/main.py | 176 +++--- .../examples/tools.py | 68 ++- .../src/opentelemetry/__init__.py | 2 +- .../opentelemetry/instrumentation/__init__.py | 2 +- .../instrumentation/google_adk/__init__.py | 87 +-- .../google_adk/internal/__init__.py | 1 - .../google_adk/internal/_extractors.py | 440 +++++++++------ .../google_adk/internal/_metrics.py | 80 ++- .../google_adk/internal/_plugin.py | 528 +++++++++++------- .../google_adk/internal/_utils.py | 106 ++-- .../instrumentation/google_adk/package.py | 1 - .../instrumentation/google_adk/version.py | 1 - .../tests/__init__.py | 1 - .../tests/test_metrics.py | 521 ++++++++++------- .../tests/test_plugin_integration.py | 424 ++++++++------ .../tests/test_utils.py | 184 +++--- 16 files changed, 1526 insertions(+), 1096 deletions(-) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py index 47cc4284..24dcc3bb 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py @@ -4,12 +4,12 @@ 展示如何在 ADK Agent 中使用各种工具函数并部署为 HTTP 服务 """ -import os -import sys import asyncio import logging +import os +import sys from datetime import datetime -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional import uvicorn from fastapi import FastAPI, HTTPException, Request @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) # 检查环境变量 -api_key = os.getenv('DASHSCOPE_API_KEY') +api_key = os.getenv("DASHSCOPE_API_KEY") if not api_key: print("❌ 请设置 DASHSCOPE_API_KEY 环境变量:") print(" export DASHSCOPE_API_KEY='your-dashscope-api-key'") @@ -32,9 +32,11 @@ # 导入 ADK 相关模块 from google.adk.agents import LlmAgent from google.adk.models.lite_llm import LiteLlm - from google.adk.tools import FunctionTool from google.adk.runners import Runner - from google.adk.sessions.in_memory_session_service import InMemorySessionService + 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}") @@ -45,13 +47,13 @@ # 导入自定义工具 try: from tools import ( - get_current_time, calculate_math, - roll_dice, check_prime_numbers, + get_current_time, get_weather_info, + roll_dice, search_web, - translate_text + translate_text, ) except ImportError as e: print(f"❌ 导入自定义工具失败: {e}") @@ -62,72 +64,78 @@ "model": "dashscope/qwen-plus", "api_key": api_key, "temperature": 0.7, - "max_tokens": 1000 + "max_tokens": 1000, } # 设置LiteLLM的环境变量 -os.environ['DASHSCOPE_API_KEY'] = api_key +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: + if hasattr(content, "parts") and content.parts: text_parts = [] for part in content.parts: - if hasattr(part, 'text') and part.text: + if hasattr(part, "text") and part.text: text_parts.append(part.text) - return ''.join(text_parts) - + return "".join(text_parts) + # 如果 content 有 text 属性 - if hasattr(content, 'text') and 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" + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", ) - + # 创建工具 time_tool = FunctionTool(func=get_current_time) calc_tool = FunctionTool(func=calculate_math) @@ -136,7 +144,7 @@ async def create_agent() -> LlmAgent: 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", @@ -166,12 +174,13 @@ async def create_agent() -> LlmAgent: prime_tool, weather_tool, search_tool, - translate_tool - ] + translate_tool, + ], ) - + return agent + # ==================== 服务实现 ==================== # 全局变量存储服务组件 @@ -179,10 +188,11 @@ async def create_agent() -> LlmAgent: runner = None agent = None + async def initialize_services(): """初始化服务组件""" global session_service, runner, agent - + if session_service is None: logger.info("🔧 初始化服务组件...") session_service = InMemorySessionService() @@ -190,68 +200,69 @@ async def initialize_services(): runner = Runner( app_name="tools_agent_demo", agent=agent, - session_service=session_service + session_service=session_service, ) logger.info("✅ 服务组件初始化完成") -async def run_conversation(user_input: str, user_id: str, session_id: str = "default_session") -> str: + +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 + 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)] + 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 + 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: + 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 + 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" + version="1.0.0", ) + @app.on_event("startup") async def startup_event(): """应用启动时初始化服务""" @@ -259,6 +270,7 @@ async def startup_event(): await initialize_services() logger.info("✅ 服务启动完成") + @app.get("/") async def root(): """服务状态检查""" @@ -275,45 +287,47 @@ async def root(): "check_prime_numbers: 质数检查", "get_weather_info: 天气信息", "search_web: 网络搜索", - "translate_text: 文本翻译" + "translate_text: 文本翻译", ], "capabilities": [ "工具自动调用", "多种实用功能", "智能任务处理", - "结果整合分析" - ] + "结果整合分析", + ], }, - timestamp=datetime.now().isoformat() + 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')}" - + 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 + session_id=session_id, ) - + return ApiResponse( success=True, message="工具任务处理成功", - data={ - "task": request.task, - "response": response - }, + data={"task": request.task, "response": response}, timestamp=datetime.now().isoformat(), - session_id=session_id + 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): """全局异常处理""" @@ -323,15 +337,16 @@ async def global_exception_handler(request: Request, exc: Exception): content={ "success": False, "message": f"服务器内部错误: {str(exc)}", - "timestamp": datetime.now().isoformat() - } + "timestamp": datetime.now().isoformat(), + }, ) + def main(): """主函数 - 启动 HTTP 服务""" print("🚀 ADK 工具使用 Agent HTTP 服务") print("=" * 50) - print(f"🔑 API Key 已设置") + print("🔑 API Key 已设置") print("🔧 可用工具:") print(" 1. get_current_time - 获取当前时间") print(" 2. calculate_math - 数学计算") @@ -347,32 +362,33 @@ def main(): print("\n💡 示例请求:") print(" curl -X POST http://localhost:8000/tools \\") print(" -H 'Content-Type: application/json' \\") - print(" -d '{\"task\": \"现在几点了?\"}'") + print(' -d \'{"task": "现在几点了?"}\'') print("\n🌐 启动服务...") - + # 启动 FastAPI 服务 uvicorn.run( "main:app", host="0.0.0.0", port=8000, log_level="info", - access_log=True + access_log=True, ) + # 保留原有的命令行测试功能 async def run_test_conversation(): """运行测试对话""" print("🚀 启动工具使用示例") print("=" * 50) - print(f"🔑 API Key 已设置") + print("🔑 API Key 已设置") print(f"🤖 模型: {DASHSCOPE_CONFIG['model']}") print("=" * 50) - + try: # 初始化服务 await initialize_services() print("✅ Agent 初始化成功") - + # 示例对话 test_inputs = [ "现在几点了?", @@ -381,31 +397,35 @@ async def run_test_conversation(): "检查 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) + + 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": diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py index 3f710c65..9ce4d027 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py @@ -6,26 +6,27 @@ import math import random -import json from datetime import datetime -from typing import List, Dict, Any +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: 计算结果的字符串 """ @@ -34,20 +35,23 @@ def calculate_math(expression: str) -> str: 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}) - + 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: 掷骰子的结果 """ @@ -55,16 +59,18 @@ def roll_dice(sides: int = 6) -> int: 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 @@ -76,32 +82,33 @@ def is_prime(n): 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}" + "summary": f"在 {numbers} 中,质数有: {primes},非质数有: {non_primes}", } + def get_weather_info(city: str) -> str: """ 获取天气信息工具函数(模拟) - + Args: city: 城市名称 - + Returns: 天气信息字符串 """ @@ -111,19 +118,20 @@ def get_weather_info(city: str) -> str: "上海": "多云,温度 18°C,湿度 60%,东南风", "深圳": "小雨,温度 25°C,湿度 80%,南风", "杭州": "阴天,温度 20°C,湿度 55%,西北风", - "广州": "晴朗,温度 28°C,湿度 65%,东风" + "广州": "晴朗,温度 28°C,湿度 65%,东风", } - + weather = weather_data.get(city, f"{city}的天气信息暂时无法获取") return f"📍 {city}的天气:{weather}" + def search_web(query: str) -> str: """ 网络搜索工具函数(模拟) - + Args: query: 搜索查询 - + Returns: 搜索结果字符串 """ @@ -132,23 +140,24 @@ def search_web(query: str) -> str: "人工智能": "人工智能是计算机科学的一个分支,它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。", "机器学习": "机器学习是人工智能的一个分支,是一门多领域交叉学科,涉及概率论、统计学、逼近论、凸分析、算法复杂度理论等多门学科。", "深度学习": "深度学习是机器学习的一个分支,它基于人工神经网络,利用多层非线性变换对数据进行特征提取和转换。", - "自然语言处理": "自然语言处理是计算机科学领域与人工智能领域中的一个重要方向,它研究能实现人与计算机之间用自然语言进行有效通信的各种理论和方法。" + "自然语言处理": "自然语言处理是计算机科学领域与人工智能领域中的一个重要方向,它研究能实现人与计算机之间用自然语言进行有效通信的各种理论和方法。", } - + 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: 翻译结果字符串 """ @@ -158,14 +167,15 @@ def translate_text(text: str, target_language: str = "en") -> str: "谢谢": "Thank you", "再见": "Goodbye", "人工智能": "Artificial Intelligence", - "机器学习": "Machine Learning" + "机器学习": "Machine Learning", } - + if target_language.lower() == "en": return translations.get(text, f"Translated: {text}") else: return f"翻译到{target_language}:{text}" + # 导出所有工具函数 __all__ = [ "get_current_time", @@ -174,5 +184,5 @@ def translate_text(text: str, target_language: str = "en") -> str: "check_prime_numbers", "get_weather_info", "search_web", - "translate_text" + "translate_text", ] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py index a3223933..4d950a58 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py @@ -1,3 +1,3 @@ """OpenTelemetry namespace package.""" -__path__ = __import__('pkgutil').extend_path(__path__, __name__) +__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 index 00c9fac6..6dfadabb 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py @@ -1,3 +1,3 @@ """OpenTelemetry instrumentation namespace package.""" -__path__ = __import__('pkgutil').extend_path(__path__, __name__) +__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 index ab7651af..4b5a0f4f 100644 --- 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 @@ -8,7 +8,7 @@ # Manual instrumentation from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor GoogleAdkInstrumentor().instrument() - + # Auto instrumentation (via opentelemetry-instrument) # opentelemetry-instrument python your_app.py """ @@ -16,11 +16,13 @@ import logging from typing import Collection, Optional -from opentelemetry import trace as trace_api, metrics as metrics_api +from wrapt import wrap_function_wrapper + +from opentelemetry import metrics as metrics_api +from opentelemetry import trace as trace_api from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.utils import unwrap from opentelemetry.semconv.schemas import Schemas -from wrapt import wrap_function_wrapper from .internal._plugin import GoogleAdkObservabilityPlugin from .version import __version__ @@ -36,20 +38,20 @@ def _create_plugin_if_needed(tracer_provider=None, meter_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 - + Returns: GoogleAdkObservabilityPlugin instance """ global _global_plugin - + if _global_plugin is None: # Get tracer and meter tracer = trace_api.get_tracer( @@ -58,52 +60,54 @@ def _create_plugin_if_needed(tracer_provider=None, meter_provider=None): tracer_provider, schema_url=Schemas.V1_28_0.value, ) - + meter = metrics_api.get_meter( __name__, __version__, meter_provider, schema_url=Schemas.V1_28_0.value, ) - + _global_plugin = GoogleAdkObservabilityPlugin(tracer, meter) _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', []) + 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") - + kwargs["plugins"] = plugins + _logger.debug( + "Injected OpenTelemetry observability plugin into Runner" + ) + # Call the original __init__ return wrapped(*args, **kwargs) @@ -111,15 +115,15 @@ def _runner_init_wrapper(wrapped, instance, args, kwargs): class GoogleAdkInstrumentor(BaseInstrumentor): """ OpenTelemetry instrumentor for Google ADK. - + This instrumentor automatically injects observability into Google ADK applications following OpenTelemetry 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__() @@ -128,10 +132,10 @@ def __init__(self): 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 """ @@ -140,7 +144,7 @@ def _plugin(self): def instrumentation_dependencies(self) -> Collection[str]: """ Return the list of instrumentation dependencies. - + Returns: Collection of required packages """ @@ -149,35 +153,37 @@ def instrumentation_dependencies(self) -> Collection[str]: 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 """ - # Lazy import to avoid import errors when google-adk is not installed - try: - import google.adk.runners - except ImportError: - _logger.warning("google-adk not found, instrumentation will not be applied") + # Check if google-adk is installed + import importlib.util + + 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") - + # Create or get the global plugin instance _create_plugin_if_needed(tracer_provider, meter_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 + _runner_init_wrapper, # Use module-level function ) _logger.info("Google ADK instrumentation enabled") except Exception as e: @@ -186,20 +192,21 @@ def _instrument(self, **kwargs): def _uninstrument(self, **kwargs): """ Uninstrument the Google ADK library. - + Args: **kwargs: Optional keyword arguments """ global _global_plugin - + try: # Unwrap the Runner initialization from google.adk.runners import Runner + unwrap(Runner, "__init__") - + # Clear the global plugin _global_plugin = None - + _logger.info("Google ADK instrumentation disabled") except Exception as e: _logger.exception(f"Failed to uninstrument Google ADK: {e}") 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 index c67545b6..1d78902e 100644 --- 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 @@ -1,2 +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 index 28156c4d..bfd7abda 100644 --- 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 @@ -1,11 +1,10 @@ """ ADK Attribute Extractors following OpenTelemetry GenAI Semantic Conventions. -This module extracts trace attributes from Google ADK objects according +This module extracts trace attributes from Google ADK objects according to OpenTelemetry GenAI semantic conventions (latest version). """ -import json import logging from typing import Any, Dict, Optional @@ -18,9 +17,12 @@ from google.adk.tools.tool_context import ToolContext from ._utils import ( - safe_json_dumps, safe_json_dumps_large, extract_content_safely, - safe_json_dumps_for_input_output, extract_content_safely_for_input_output, - should_capture_content, process_content + extract_content_safely_for_input_output, + process_content, + safe_json_dumps, + safe_json_dumps_for_input_output, + safe_json_dumps_large, + should_capture_content, ) _logger = logging.getLogger(__name__) @@ -29,184 +31,199 @@ 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_common_attributes( - self, + self, operation_name: str, conversation_id: Optional[str] = None, - user_id: Optional[str] = None + user_id: Optional[str] = None, ) -> Dict[str, Any]: """ Extract common GenAI attributes required for all spans. - + Args: operation_name: Operation name (chat, invoke_agent, execute_tool, etc.) conversation_id: Conversation/session ID (optional) user_id: User ID (optional) - + Returns: Dictionary of common attributes """ attrs = { "gen_ai.operation.name": operation_name, - "gen_ai.provider.name": "google_adk" # ✅ 使用 provider.name 而非 system + "gen_ai.provider.name": "google_adk", # ✅ 使用 provider.name 而非 system } - + # ✅ conversation.id 而非 session.id if conversation_id and isinstance(conversation_id, str): attrs["gen_ai.conversation.id"] = conversation_id - - # ✅ 使用标准 enduser.id 而非 gen_ai.user.id + + # ✅ 使用标准 enduser.id 而非 gen_ai.user.id if user_id and isinstance(user_id, str): attrs["enduser.id"] = user_id - + return attrs def extract_runner_attributes( - self, - invocation_context: InvocationContext + self, invocation_context: InvocationContext ) -> Dict[str, Any]: """ Extract attributes for Runner spans (top-level invoke_agent span). - + Args: invocation_context: ADK invocation context - + Returns: Dictionary of runner attributes """ try: _logger.debug("Extracting runner attributes") - + # Extract conversation_id and user_id from invocation_context conversation_id = None user_id = None - + try: conversation_id = invocation_context.session.id except AttributeError: - _logger.debug("Failed to extract conversation_id from invocation_context") - + _logger.debug( + "Failed to extract conversation_id from invocation_context" + ) + try: - user_id = getattr(invocation_context, 'user_id', None) - if not user_id and hasattr(invocation_context, 'session'): - user_id = getattr(invocation_context.session, 'user_id', None) + user_id = getattr(invocation_context, "user_id", None) + if not user_id and hasattr(invocation_context, "session"): + user_id = getattr( + invocation_context.session, "user_id", None + ) except AttributeError: - _logger.debug("Failed to extract user_id from invocation_context") + _logger.debug( + "Failed to extract user_id from invocation_context" + ) if conversation_id is None: - _logger.debug("conversation_id not found on invocation_context") + _logger.debug( + "conversation_id not found on invocation_context" + ) if user_id is None: _logger.debug("user_id not found on invocation_context") - + # ✅ 使用 invoke_agent 操作名称 attrs = self.extract_common_attributes( operation_name="invoke_agent", conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + # Add ADK-specific attributes (非标准,作为自定义扩展) - if hasattr(invocation_context, 'app_name'): - attrs["google_adk.runner.app_name"] = invocation_context.app_name - - if hasattr(invocation_context, 'invocation_id'): - attrs["google_adk.runner.invocation_id"] = invocation_context.invocation_id - + if hasattr(invocation_context, "app_name"): + attrs["google_adk.runner.app_name"] = ( + invocation_context.app_name + ) + + if hasattr(invocation_context, "invocation_id"): + attrs["google_adk.runner.invocation_id"] = ( + invocation_context.invocation_id + ) + # Agent spans use input.value/output.value attrs["input.mime_type"] = "application/json" attrs["output.mime_type"] = "application/json" - + return attrs - + except Exception as e: _logger.exception(f"Error extracting runner attributes: {e}") return self.extract_common_attributes("invoke_agent") def extract_agent_attributes( - self, - agent: BaseAgent, - callback_context: CallbackContext + self, agent: BaseAgent, callback_context: CallbackContext ) -> Dict[str, Any]: """ Extract attributes for Agent spans. - + Args: agent: ADK agent instance callback_context: ADK callback context - + Returns: Dictionary of agent attributes """ try: _logger.debug("Extracting agent attributes") - + # Extract conversation_id and user_id from callback_context conversation_id = None user_id = None - + try: - conversation_id = callback_context._invocation_context.session.id + conversation_id = ( + callback_context._invocation_context.session.id + ) except AttributeError: - _logger.debug("Failed to extract conversation_id from callback_context") - + _logger.debug( + "Failed to extract conversation_id from callback_context" + ) + try: - user_id = getattr(callback_context, 'user_id', None) + user_id = getattr(callback_context, "user_id", None) if not user_id: - user_id = getattr(callback_context._invocation_context, 'user_id', None) + user_id = getattr( + callback_context._invocation_context, "user_id", None + ) except AttributeError: - _logger.debug("Failed to extract user_id from callback_context") + _logger.debug( + "Failed to extract user_id from callback_context" + ) if conversation_id is None: _logger.debug("conversation_id not found on callback_context") if user_id is None: _logger.debug("user_id not found on callback_context") - + # ✅ 使用 invoke_agent 操作名称(无论是 agent 还是 chain) attrs = self.extract_common_attributes( operation_name="invoke_agent", conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + # ✅ 使用 gen_ai.agent.* 属性(带前缀) - if hasattr(agent, 'name') and agent.name: + if hasattr(agent, "name") and agent.name: attrs["gen_ai.agent.name"] = agent.name - + # ✅ 尝试获取 agent.id(如果可用) - if hasattr(agent, 'id') and agent.id: + if hasattr(agent, "id") and agent.id: attrs["gen_ai.agent.id"] = agent.id - - if hasattr(agent, 'description') and agent.description: + + if hasattr(agent, "description") and agent.description: attrs["gen_ai.agent.description"] = agent.description - + # Add input/output placeholder attrs["input.mime_type"] = "application/json" attrs["output.mime_type"] = "application/json" - + return attrs - + except Exception as e: _logger.exception(f"Error extracting agent attributes: {e}") return self.extract_common_attributes("invoke_agent") def extract_llm_request_attributes( - self, - llm_request: LlmRequest, - callback_context: CallbackContext + self, llm_request: LlmRequest, callback_context: CallbackContext ) -> Dict[str, Any]: """ Extract attributes for LLM request spans. - + Args: llm_request: ADK LLM request callback_context: ADK callback context - + Returns: Dictionary of LLM request attributes """ @@ -214,178 +231,246 @@ def extract_llm_request_attributes( # Extract conversation_id and user_id conversation_id = None user_id = None - + try: - conversation_id = callback_context._invocation_context.session.id + conversation_id = ( + callback_context._invocation_context.session.id + ) except AttributeError: - _logger.debug("Failed to extract conversation_id from callback_context") - + _logger.debug( + "Failed to extract conversation_id from callback_context" + ) + try: - user_id = getattr(callback_context, 'user_id', None) + user_id = getattr(callback_context, "user_id", None) if not user_id: - user_id = getattr(callback_context._invocation_context, 'user_id', None) + user_id = getattr( + callback_context._invocation_context, "user_id", None + ) except AttributeError: - _logger.debug("Failed to extract user_id from callback_context") - + _logger.debug( + "Failed to extract user_id from callback_context" + ) + # ✅ 使用 chat 操作名称 attrs = self.extract_common_attributes( operation_name="chat", conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + # Add LLM request attributes according to GenAI conventions - if hasattr(llm_request, 'model') and llm_request.model: + if hasattr(llm_request, "model") and llm_request.model: # ✅ 只使用 gen_ai.request.model(移除冗余的 model_name) attrs["gen_ai.request.model"] = llm_request.model # ✅ 使用 _extract_provider_name 而非 _extract_system_from_model - attrs["gen_ai.provider.name"] = self._extract_provider_name(llm_request.model) - + attrs["gen_ai.provider.name"] = self._extract_provider_name( + llm_request.model + ) + # Extract request parameters - if hasattr(llm_request, 'config') and llm_request.config: + if hasattr(llm_request, "config") and llm_request.config: config = llm_request.config - - if hasattr(config, 'max_tokens') and config.max_tokens: + + if hasattr(config, "max_tokens") and config.max_tokens: attrs["gen_ai.request.max_tokens"] = config.max_tokens - - if hasattr(config, 'temperature') and config.temperature is not None: + + if ( + hasattr(config, "temperature") + and config.temperature is not None + ): if isinstance(config.temperature, (int, float)): - attrs["gen_ai.request.temperature"] = config.temperature - - if hasattr(config, 'top_p') and config.top_p is not None: + attrs["gen_ai.request.temperature"] = ( + config.temperature + ) + + if hasattr(config, "top_p") and config.top_p is not None: if isinstance(config.top_p, (int, float)): attrs["gen_ai.request.top_p"] = config.top_p - - if hasattr(config, 'top_k') and config.top_k is not None: + + if hasattr(config, "top_k") and config.top_k is not None: if isinstance(config.top_k, (int, float)): attrs["gen_ai.request.top_k"] = config.top_k - + # Extract input messages (with content capture control) - if should_capture_content() and hasattr(llm_request, 'contents') and llm_request.contents: + if ( + should_capture_content() + and hasattr(llm_request, "contents") + and llm_request.contents + ): try: input_messages = [] for content in llm_request.contents: - if hasattr(content, 'role') and hasattr(content, 'parts'): + if hasattr(content, "role") and hasattr( + content, "parts" + ): # Convert to GenAI message format - message = { - "role": content.role, - "parts": [] - } + message = {"role": content.role, "parts": []} for part in content.parts: - if hasattr(part, 'text'): - message["parts"].append({ - "type": "text", - "content": process_content(part.text) - }) + if hasattr(part, "text"): + message["parts"].append( + { + "type": "text", + "content": process_content( + part.text + ), + } + ) input_messages.append(message) - + if input_messages: - attrs["gen_ai.input.messages"] = safe_json_dumps_large(input_messages) - + attrs["gen_ai.input.messages"] = safe_json_dumps_large( + input_messages + ) + except Exception as e: _logger.debug(f"Failed to extract input messages: {e}") - + attrs["input.mime_type"] = "application/json" # ❌ 移除 gen_ai.request.is_stream (非标准属性) - + return attrs - + except Exception as e: _logger.exception(f"Error extracting LLM request attributes: {e}") return self.extract_common_attributes("chat") def extract_llm_response_attributes( - self, - llm_response: LlmResponse + self, llm_response: LlmResponse ) -> Dict[str, Any]: """ Extract attributes for LLM response. - + Args: llm_response: ADK LLM response - + Returns: Dictionary of LLM response attributes """ try: attrs = {} - + # Add response model - if hasattr(llm_response, 'model') and llm_response.model: + if hasattr(llm_response, "model") and llm_response.model: attrs["gen_ai.response.model"] = llm_response.model - + # ✅ finish_reasons (复数数组) - if hasattr(llm_response, 'finish_reason'): - finish_reason = llm_response.finish_reason or 'stop' - attrs["gen_ai.response.finish_reasons"] = [finish_reason] # 必须是数组 - + if hasattr(llm_response, "finish_reason"): + finish_reason = llm_response.finish_reason or "stop" + attrs["gen_ai.response.finish_reasons"] = [ + finish_reason + ] # 必须是数组 + # Add token usage - if hasattr(llm_response, 'usage_metadata') and llm_response.usage_metadata: + if ( + hasattr(llm_response, "usage_metadata") + and llm_response.usage_metadata + ): usage = llm_response.usage_metadata - - if hasattr(usage, 'prompt_token_count') and usage.prompt_token_count: - attrs["gen_ai.usage.input_tokens"] = usage.prompt_token_count - - if hasattr(usage, 'candidates_token_count') and usage.candidates_token_count: - attrs["gen_ai.usage.output_tokens"] = usage.candidates_token_count + + if ( + hasattr(usage, "prompt_token_count") + and usage.prompt_token_count + ): + attrs["gen_ai.usage.input_tokens"] = ( + usage.prompt_token_count + ) + + if ( + hasattr(usage, "candidates_token_count") + and usage.candidates_token_count + ): + attrs["gen_ai.usage.output_tokens"] = ( + usage.candidates_token_count + ) # ❌ 移除 gen_ai.usage.total_tokens (非标准,可自行计算) - + # Extract output messages (with content capture control) - if should_capture_content() and hasattr(llm_response, 'content') and llm_response.content: + if ( + should_capture_content() + and hasattr(llm_response, "content") + and llm_response.content + ): try: output_messages = [] # Check if response has text content - if hasattr(llm_response, 'text') and llm_response.text is not None: - extracted_text = extract_content_safely_for_input_output(llm_response.text) + if ( + hasattr(llm_response, "text") + and llm_response.text is not None + ): + extracted_text = ( + extract_content_safely_for_input_output( + llm_response.text + ) + ) message = { "role": "assistant", - "parts": [{ - "type": "text", - "content": process_content(extracted_text) - }], - "finish_reason": getattr(llm_response, 'finish_reason', None) or 'stop' + "parts": [ + { + "type": "text", + "content": process_content(extracted_text), + } + ], + "finish_reason": getattr( + llm_response, "finish_reason", None + ) + or "stop", } output_messages.append(message) - elif hasattr(llm_response, 'content') and llm_response.content is not None: - extracted_text = extract_content_safely_for_input_output(llm_response.content) + elif ( + hasattr(llm_response, "content") + and llm_response.content is not None + ): + extracted_text = ( + extract_content_safely_for_input_output( + llm_response.content + ) + ) message = { "role": "assistant", - "parts": [{ - "type": "text", - "content": process_content(extracted_text) - }], - "finish_reason": getattr(llm_response, 'finish_reason', None) or 'stop' + "parts": [ + { + "type": "text", + "content": process_content(extracted_text), + } + ], + "finish_reason": getattr( + llm_response, "finish_reason", None + ) + or "stop", } output_messages.append(message) - + if output_messages: - attrs["gen_ai.output.messages"] = safe_json_dumps_large(output_messages) - + attrs["gen_ai.output.messages"] = ( + safe_json_dumps_large(output_messages) + ) + except Exception as e: _logger.debug(f"Failed to extract output messages: {e}") - + attrs["output.mime_type"] = "application/json" - + return attrs - + except Exception as e: _logger.exception(f"Error extracting LLM response attributes: {e}") return {} def extract_tool_attributes( - self, + self, tool: BaseTool, tool_args: dict[str, Any], - tool_context: ToolContext + tool_context: ToolContext, ) -> Dict[str, Any]: """ Extract attributes for Tool spans. - + Args: tool: ADK tool instance tool_args: Tool arguments tool_context: Tool context - + Returns: Dictionary of tool attributes """ @@ -393,41 +478,47 @@ def extract_tool_attributes( # 尝试从tool_context提取conversation_id conversation_id = None user_id = None - - if hasattr(tool_context, 'session_id'): + + if hasattr(tool_context, "session_id"): conversation_id = tool_context.session_id - elif hasattr(tool_context, 'context') and hasattr(tool_context.context, 'session_id'): + elif hasattr(tool_context, "context") and hasattr( + tool_context.context, "session_id" + ): conversation_id = tool_context.context.session_id - + # ✅ 使用 execute_tool 操作名称 attrs = self.extract_common_attributes( operation_name="execute_tool", conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + # ✅ Tool 属性使用 gen_ai.tool.* 前缀 - if hasattr(tool, 'name') and tool.name: + if hasattr(tool, "name") and tool.name: attrs["gen_ai.tool.name"] = tool.name - - if hasattr(tool, 'description') and tool.description: + + if hasattr(tool, "description") and tool.description: attrs["gen_ai.tool.description"] = tool.description - + # ✅ 默认 tool type 为 function attrs["gen_ai.tool.type"] = "function" - + # ✅ 尝试获取 tool.call.id(如果可用) - if hasattr(tool_context, 'call_id') and tool_context.call_id: + if hasattr(tool_context, "call_id") and tool_context.call_id: attrs["gen_ai.tool.call.id"] = tool_context.call_id - + # ✅ tool.call.arguments 而非 tool.parameters (Opt-In) if should_capture_content() and tool_args: - attrs["gen_ai.tool.call.arguments"] = safe_json_dumps(tool_args) - attrs["input.value"] = safe_json_dumps_for_input_output(tool_args) + attrs["gen_ai.tool.call.arguments"] = safe_json_dumps( + tool_args + ) + attrs["input.value"] = safe_json_dumps_for_input_output( + tool_args + ) attrs["input.mime_type"] = "application/json" - + return attrs - + except Exception as e: _logger.exception(f"Error extracting tool attributes: {e}") return self.extract_common_attributes("execute_tool") @@ -435,18 +526,18 @@ def extract_tool_attributes( 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 @@ -468,4 +559,3 @@ def _extract_provider_name(self, model_name: str) -> str: 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/_metrics.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py index ed0e9202..ea3d1374 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_metrics.py @@ -17,15 +17,15 @@ class Instruments: """ Standard OpenTelemetry GenAI instrumentation instruments. - + This class follows the same pattern as openai-v2/instruments.py and implements only the 2 standard GenAI client metrics. """ - + def __init__(self, meter: Meter): """ Initialize standard GenAI instruments. - + Args: meter: OpenTelemetry meter instance """ @@ -33,7 +33,7 @@ def __init__(self, meter: Meter): self.operation_duration_histogram = ( gen_ai_metrics.create_gen_ai_client_operation_duration(meter) ) - + # ✅ Standard GenAI client metric 2: Token usage self.token_usage_histogram = ( gen_ai_metrics.create_gen_ai_client_token_usage(meter) @@ -43,23 +43,25 @@ def __init__(self, meter: Meter): class AdkMetricsCollector: """ Metrics collector for Google ADK following OpenTelemetry GenAI conventions. - + This collector implements ONLY the 2 standard GenAI client metrics: - gen_ai.client.operation.duration (Histogram, unit: seconds) - gen_ai.client.token.usage (Histogram, unit: tokens) - + All ARMS-specific metrics have been removed. """ - + def __init__(self, meter: Meter): """ Initialize the metrics collector. - + Args: meter: OpenTelemetry meter instance """ self._instruments = Instruments(meter) - _logger.debug("AdkMetricsCollector initialized with standard OTel GenAI metrics") + _logger.debug( + "AdkMetricsCollector initialized with standard OTel GenAI metrics" + ) def record_llm_call( self, @@ -70,11 +72,11 @@ def record_llm_call( prompt_tokens: int = 0, completion_tokens: int = 0, conversation_id: Optional[str] = None, - user_id: Optional[str] = None + user_id: Optional[str] = None, ) -> None: """ Record LLM call metrics following standard OTel GenAI conventions. - + Args: operation_name: Operation name (e.g., "chat") model_name: Model name @@ -90,19 +92,18 @@ def record_llm_call( attributes = { "gen_ai.operation.name": operation_name, "gen_ai.provider.name": "google_adk", # ✅ Required attribute - "gen_ai.request.model": model_name, # ✅ Recommended attribute + "gen_ai.request.model": model_name, # ✅ Recommended attribute } - + # ✅ Add error.type only if error occurred (Conditionally Required) if error_type: attributes["error.type"] = error_type - + # ✅ Record operation duration (Histogram, unit: seconds) self._instruments.operation_duration_histogram.record( - duration, - attributes=attributes + duration, attributes=attributes ) - + # ✅ Record token usage (Histogram, unit: tokens) # Note: session_id and user_id are NOT included in metrics (high cardinality) if prompt_tokens > 0: @@ -111,24 +112,24 @@ def record_llm_call( attributes={ **attributes, "gen_ai.token.type": "input", # ✅ Required for token.usage - } + }, ) - + if completion_tokens > 0: self._instruments.token_usage_histogram.record( completion_tokens, attributes={ **attributes, "gen_ai.token.type": "output", # ✅ Required for token.usage - } + }, ) - + _logger.debug( f"Recorded LLM metrics: operation={operation_name}, model={model_name}, " f"duration={duration:.3f}s, prompt_tokens={prompt_tokens}, " f"completion_tokens={completion_tokens}, error={error_type}" ) - + except Exception as e: _logger.exception(f"Error recording LLM metrics: {e}") @@ -139,11 +140,11 @@ def record_agent_call( duration: float, error_type: Optional[str] = None, conversation_id: Optional[str] = None, - user_id: Optional[str] = None + user_id: Optional[str] = None, ) -> None: """ Record Agent call metrics following standard OTel GenAI conventions. - + Args: operation_name: Operation name (e.g., "invoke_agent") agent_name: Agent name @@ -157,24 +158,23 @@ def record_agent_call( attributes = { "gen_ai.operation.name": operation_name, "gen_ai.provider.name": "google_adk", # ✅ Required - "gen_ai.request.model": agent_name, # ✅ Agent name as model + "gen_ai.request.model": agent_name, # ✅ Agent name as model } - + # ✅ Add error.type only if error occurred if error_type: attributes["error.type"] = error_type - + # ✅ Record operation duration (Histogram, unit: seconds) self._instruments.operation_duration_histogram.record( - duration, - attributes=attributes + duration, attributes=attributes ) - + _logger.debug( f"Recorded Agent metrics: operation={operation_name}, agent={agent_name}, " f"duration={duration:.3f}s, error={error_type}" ) - + except Exception as e: _logger.exception(f"Error recording Agent metrics: {e}") @@ -185,11 +185,11 @@ def record_tool_call( duration: float, error_type: Optional[str] = None, conversation_id: Optional[str] = None, - user_id: Optional[str] = None + user_id: Optional[str] = None, ) -> None: """ Record Tool call metrics following standard OTel GenAI conventions. - + Args: operation_name: Operation name (e.g., "execute_tool") tool_name: Tool name @@ -203,24 +203,22 @@ def record_tool_call( attributes = { "gen_ai.operation.name": operation_name, "gen_ai.provider.name": "google_adk", # ✅ Required - "gen_ai.request.model": tool_name, # ✅ Tool name as model + "gen_ai.request.model": tool_name, # ✅ Tool name as model } - + # ✅ Add error.type only if error occurred if error_type: attributes["error.type"] = error_type - + # ✅ Record operation duration (Histogram, unit: seconds) self._instruments.operation_duration_histogram.record( - duration, - attributes=attributes + duration, attributes=attributes ) - + _logger.debug( f"Recorded Tool metrics: operation={operation_name}, tool={tool_name}, " f"duration={duration:.3f}s, error={error_type}" ) - + except Exception as e: _logger.exception(f"Error recording Tool metrics: {e}") - 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 index ff422b24..a17cf455 100644 --- 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 @@ -1,7 +1,7 @@ """ OpenTelemetry ADK Observability Plugin. -This module implements the core observability plugin using Google ADK's +This module implements the core observability plugin using Google ADK's plugin mechanism with OpenTelemetry GenAI semantic conventions. """ @@ -18,15 +18,18 @@ from google.adk.tools.base_tool import BaseTool from google.adk.tools.tool_context import ToolContext from google.genai import types + from opentelemetry import trace as trace_api from opentelemetry.metrics import Meter from opentelemetry.trace import SpanKind -from ._metrics import AdkMetricsCollector from ._extractors import AdkAttributeExtractors +from ._metrics import AdkMetricsCollector from ._utils import ( - safe_json_dumps, safe_json_dumps_for_input_output, extract_content_safely_for_input_output, - should_capture_content, process_content + extract_content_safely_for_input_output, + process_content, + safe_json_dumps_for_input_output, + should_capture_content, ) _logger = logging.getLogger(__name__) @@ -35,7 +38,7 @@ class GoogleAdkObservabilityPlugin(BasePlugin): """ OpenTelemetry ADK Observability Plugin. - + Implements comprehensive observability for Google ADK applications following OpenTelemetry GenAI semantic conventions. """ @@ -43,7 +46,7 @@ class GoogleAdkObservabilityPlugin(BasePlugin): def __init__(self, tracer: trace_api.Tracer, meter: Meter): """ Initialize the observability plugin. - + Args: tracer: OpenTelemetry tracer instance meter: OpenTelemetry meter instance @@ -52,10 +55,10 @@ def __init__(self, tracer: trace_api.Tracer, meter: Meter): self._tracer = tracer self._metrics = AdkMetricsCollector(meter) self._extractors = AdkAttributeExtractors() - + # Track active spans for proper nesting self._active_spans: Dict[str, trace_api.Span] = {} - + # Track user messages and final responses for Runner spans self._runner_inputs: Dict[str, types.Content] = {} self._runner_outputs: Dict[str, str] = {} @@ -64,76 +67,95 @@ def __init__(self, tracer: trace_api.Tracer, meter: Meter): 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. Span name: "invoke_agent {app_name}" """ try: # ✅ Span name follows GenAI conventions span_name = f"invoke_agent {invocation_context.app_name}" - attributes = self._extractors.extract_runner_attributes(invocation_context) - + attributes = self._extractors.extract_runner_attributes( + invocation_context + ) + # ✅ Use CLIENT span kind (recommended for GenAI) span = self._tracer.start_span( - name=span_name, - kind=SpanKind.CLIENT, - attributes=attributes + name=span_name, kind=SpanKind.CLIENT, attributes=attributes ) - + # Store span for later use - self._active_spans[f"runner_{invocation_context.invocation_id}"] = span - + self._active_spans[ + f"runner_{invocation_context.invocation_id}" + ] = span + # Check if we already have a stored user message runner_key = f"runner_{invocation_context.invocation_id}" if runner_key in self._runner_inputs and should_capture_content(): user_message = self._runner_inputs[runner_key] - input_messages = self._convert_user_message_to_genai_format(user_message) - + input_messages = self._convert_user_message_to_genai_format( + user_message + ) + if input_messages: # For Agent spans, use input.value - span.set_attribute("input.value", safe_json_dumps_for_input_output(input_messages)) - _logger.debug(f"Set input.value on Agent span: {invocation_context.invocation_id}") - + span.set_attribute( + "input.value", + safe_json_dumps_for_input_output(input_messages), + ) + _logger.debug( + f"Set input.value on Agent span: {invocation_context.invocation_id}" + ) + _logger.debug(f"Started Runner span: {span_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 + 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 - + # Set input messages on active Runner span if it exists and content capture is enabled span = self._active_spans.get(runner_key) if span and should_capture_content(): - input_messages = self._convert_user_message_to_genai_format(user_message) - + input_messages = self._convert_user_message_to_genai_format( + user_message + ) + if input_messages: # For Agent spans, use input.value - span.set_attribute("input.value", safe_json_dumps_for_input_output(input_messages)) - - _logger.debug(f"Captured user message for Runner: {invocation_context.invocation_id}") - + span.set_attribute( + "input.value", + safe_json_dumps_for_input_output(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( @@ -141,48 +163,63 @@ async def on_event_callback( ) -> Optional[Event]: """ Capture output events for Runner span. - + This callback is triggered for each event generated during execution. """ try: if not should_capture_content(): return None - + # Extract text content from event if available event_content = "" - if hasattr(event, 'content') and event.content: - event_content = extract_content_safely_for_input_output(event.content) - elif hasattr(event, 'data') and event.data: - event_content = extract_content_safely_for_input_output(event.data) - + if hasattr(event, "content") and event.content: + event_content = extract_content_safely_for_input_output( + event.content + ) + elif hasattr(event, "data") and event.data: + event_content = extract_content_safely_for_input_output( + 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 - + # Set output on active Runner span span = self._active_spans.get(runner_key) if span: - output_messages = [{ - "role": "assistant", - "parts": [{ - "type": "text", - "content": process_content(self._runner_outputs[runner_key]) - }], - "finish_reason": "stop" - }] - + output_messages = [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": process_content( + self._runner_outputs[runner_key] + ), + } + ], + "finish_reason": "stop", + } + ] + # For Agent spans, use output.value - span.set_attribute("output.value", safe_json_dumps_for_input_output(output_messages)) - - _logger.debug(f"Captured event for Runner: {invocation_context.invocation_id}") - + span.set_attribute( + "output.value", + safe_json_dumps_for_input_output(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( @@ -194,63 +231,69 @@ async def after_run_callback( try: span_key = f"runner_{invocation_context.invocation_id}" span = self._active_spans.pop(span_key, None) - + if span: # Record metrics duration = self._calculate_span_duration(span) - + # Extract conversation_id and user_id - conversation_id = invocation_context.session.id if invocation_context.session else None - user_id = getattr(invocation_context, 'user_id', None) - + conversation_id = ( + invocation_context.session.id + if invocation_context.session + else None + ) + user_id = getattr(invocation_context, "user_id", None) + self._metrics.record_agent_call( operation_name="invoke_agent", agent_name=invocation_context.app_name, duration=duration, error_type=None, conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + span.end() - _logger.debug(f"Finished Runner span for {invocation_context.app_name}") - + _logger.debug( + f"Finished Runner span for {invocation_context.app_name}" + ) + # Clean up stored data runner_key = f"runner_{invocation_context.invocation_id}" 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. - + Span name: "invoke_agent {agent.name}" """ try: # ✅ Span name follows GenAI conventions span_name = f"invoke_agent {agent.name}" - attributes = self._extractors.extract_agent_attributes(agent, callback_context) - + attributes = self._extractors.extract_agent_attributes( + agent, callback_context + ) + # ✅ Use CLIENT span kind span = self._tracer.start_span( - name=span_name, - kind=SpanKind.CLIENT, - attributes=attributes + name=span_name, kind=SpanKind.CLIENT, attributes=attributes ) - + # Store span agent_key = f"agent_{id(agent)}_{callback_context._invocation_context.session.id}" self._active_spans[agent_key] = span - + _logger.debug(f"Started Agent span: {span_name}") - + except Exception as e: _logger.exception(f"Error in before_agent_callback: {e}") @@ -263,42 +306,46 @@ async def after_agent_callback( try: agent_key = f"agent_{id(agent)}_{callback_context._invocation_context.session.id}" span = self._active_spans.pop(agent_key, None) - + if span: # Record metrics duration = self._calculate_span_duration(span) - + # Extract conversation_id and user_id conversation_id = None user_id = None if callback_context and callback_context._invocation_context: if callback_context._invocation_context.session: - conversation_id = callback_context._invocation_context.session.id - user_id = getattr(callback_context._invocation_context, 'user_id', None) - + conversation_id = ( + callback_context._invocation_context.session.id + ) + user_id = getattr( + callback_context._invocation_context, "user_id", None + ) + self._metrics.record_agent_call( operation_name="invoke_agent", agent_name=agent.name, duration=duration, error_type=None, conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + span.end() _logger.debug(f"Finished Agent span 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. - + Span name: "chat {model}" """ try: @@ -307,25 +354,23 @@ async def before_model_callback( attributes = self._extractors.extract_llm_request_attributes( llm_request, callback_context ) - + # ✅ Use CLIENT span kind for LLM calls span = self._tracer.start_span( - name=span_name, - kind=SpanKind.CLIENT, - attributes=attributes + name=span_name, kind=SpanKind.CLIENT, attributes=attributes ) - + # Store span session_id = callback_context._invocation_context.session.id request_key = f"llm_{id(llm_request)}_{session_id}" self._active_spans[request_key] = span # Store the requested model for reliable retrieval later - if hasattr(llm_request, 'model') and llm_request.model: + if hasattr(llm_request, "model") and llm_request.model: self._llm_req_models[request_key] = llm_request.model - + _logger.debug(f"Started LLM span: {span_name}") - + except Exception as e: _logger.exception(f"Error in before_model_callback: {e}") @@ -345,34 +390,50 @@ async def after_model_callback( llm_span = self._active_spans.pop(key) request_key = key break - + if llm_span: # Add response attributes - response_attrs = self._extractors.extract_llm_response_attributes(llm_response) + response_attrs = ( + self._extractors.extract_llm_response_attributes( + llm_response + ) + ) for key, value in response_attrs.items(): llm_span.set_attribute(key, value) - + # Record metrics duration = self._calculate_span_duration(llm_span) - + # Resolve model name with robust fallbacks - model_name = self._resolve_model_name(llm_response, request_key, llm_span) - + model_name = self._resolve_model_name( + llm_response, request_key, llm_span + ) + # Extract conversation_id and user_id conversation_id = None user_id = None if callback_context and callback_context._invocation_context: if callback_context._invocation_context.session: - conversation_id = callback_context._invocation_context.session.id - user_id = getattr(callback_context._invocation_context, 'user_id', None) - + conversation_id = ( + callback_context._invocation_context.session.id + ) + user_id = getattr( + callback_context._invocation_context, "user_id", None + ) + # Extract token usage prompt_tokens = 0 completion_tokens = 0 if llm_response and llm_response.usage_metadata: - prompt_tokens = getattr(llm_response.usage_metadata, 'prompt_token_count', 0) - completion_tokens = getattr(llm_response.usage_metadata, 'candidates_token_count', 0) - + prompt_tokens = getattr( + llm_response.usage_metadata, "prompt_token_count", 0 + ) + completion_tokens = getattr( + llm_response.usage_metadata, + "candidates_token_count", + 0, + ) + self._metrics.record_llm_call( operation_name="chat", model_name=model_name, @@ -381,18 +442,21 @@ async def after_model_callback( prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + llm_span.end() _logger.debug(f"Finished LLM span 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 + self, + *, + callback_context: CallbackContext, + llm_request: LlmRequest, + error: Exception, ) -> Optional[LlmResponse]: """ Handle LLM call errors. @@ -403,23 +467,34 @@ async def on_model_error_callback( for key, span in list(self._active_spans.items()): if key.startswith("llm_") and session_id in key: span = self._active_spans.pop(key) - + # Set error attributes error_type = type(error).__name__ span.set_attribute("error.type", error_type) - + # Record error metrics duration = self._calculate_span_duration(span) - model_name = llm_request.model if llm_request else "unknown" - + model_name = ( + llm_request.model if llm_request else "unknown" + ) + # Extract conversation_id and user_id conversation_id = None user_id = None - if callback_context and callback_context._invocation_context: + if ( + callback_context + and callback_context._invocation_context + ): if callback_context._invocation_context.session: - conversation_id = callback_context._invocation_context.session.id - user_id = getattr(callback_context._invocation_context, 'user_id', None) - + conversation_id = ( + callback_context._invocation_context.session.id + ) + user_id = getattr( + callback_context._invocation_context, + "user_id", + None, + ) + self._metrics.record_llm_call( operation_name="chat", model_name=model_name, @@ -428,33 +503,37 @@ async def on_model_error_callback( prompt_tokens=0, completion_tokens=0, conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + # ✅ Use standard OTel span status for errors - span.set_status(trace_api.Status( - trace_api.StatusCode.ERROR, - description=str(error) - )) + span.set_status( + trace_api.Status( + trace_api.StatusCode.ERROR, description=str(error) + ) + ) span.end() 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 + self, + *, + tool: BaseTool, + tool_args: dict[str, Any], + tool_context: ToolContext, ) -> None: """ Start Tool execution - create execute_tool span. - + Span name: "execute_tool {tool.name}" """ try: @@ -463,26 +542,28 @@ async def before_tool_callback( attributes = self._extractors.extract_tool_attributes( tool, tool_args, tool_context ) - + # ✅ Use INTERNAL span kind for tool execution (as per spec) span = self._tracer.start_span( - name=span_name, - kind=SpanKind.INTERNAL, - attributes=attributes + name=span_name, kind=SpanKind.INTERNAL, attributes=attributes ) - + # Store span tool_key = f"tool_{id(tool)}_{id(tool_args)}" self._active_spans[tool_key] = span - + _logger.debug(f"Started Tool span: {span_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 + self, + *, + tool: BaseTool, + tool_args: dict[str, Any], + tool_context: ToolContext, + result: dict, ) -> None: """ End Tool execution - finish execute_tool span and record metrics. @@ -490,7 +571,7 @@ async def after_tool_callback( try: tool_key = f"tool_{id(tool)}_{id(tool_args)}" span = self._active_spans.pop(tool_key, None) - + if span: # ✅ Add tool result as gen_ai.tool.call.result (Opt-In) if should_capture_content() and result: @@ -498,32 +579,44 @@ async def after_tool_callback( span.set_attribute("gen_ai.tool.call.result", result_json) span.set_attribute("output.value", result_json) span.set_attribute("output.mime_type", "application/json") - + # Record metrics duration = self._calculate_span_duration(span) - + # Extract conversation_id and user_id from tool_context - conversation_id = getattr(tool_context, 'session_id', None) if tool_context else None - user_id = getattr(tool_context, 'user_id', None) if tool_context else None - + conversation_id = ( + getattr(tool_context, "session_id", None) + if tool_context + else None + ) + user_id = ( + getattr(tool_context, "user_id", None) + if tool_context + else None + ) + self._metrics.record_tool_call( operation_name="execute_tool", tool_name=tool.name, duration=duration, error_type=None, conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + span.end() _logger.debug(f"Finished Tool span 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 + self, + *, + tool: BaseTool, + tool_args: dict[str, Any], + tool_context: ToolContext, + error: Exception, ) -> Optional[dict]: """ Handle Tool execution errors. @@ -531,40 +624,49 @@ async def on_tool_error_callback( try: tool_key = f"tool_{id(tool)}_{id(tool_args)}" span = self._active_spans.pop(tool_key, None) - + if span: # Set error attributes error_type = type(error).__name__ span.set_attribute("error.type", error_type) - + # Record error metrics duration = self._calculate_span_duration(span) - + # Extract conversation_id and user_id - conversation_id = getattr(tool_context, 'session_id', None) if tool_context else None - user_id = getattr(tool_context, 'user_id', None) if tool_context else None - + conversation_id = ( + getattr(tool_context, "session_id", None) + if tool_context + else None + ) + user_id = ( + getattr(tool_context, "user_id", None) + if tool_context + else None + ) + self._metrics.record_tool_call( operation_name="execute_tool", tool_name=tool.name, duration=duration, error_type=error_type, conversation_id=conversation_id, - user_id=user_id + user_id=user_id, ) - + # ✅ Use standard OTel span status for errors - span.set_status(trace_api.Status( - trace_api.StatusCode.ERROR, - description=str(error) - )) + span.set_status( + trace_api.Status( + trace_api.StatusCode.ERROR, description=str(error) + ) + ) span.end() - + _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 ===== @@ -572,88 +674,104 @@ async def on_tool_error_callback( def _calculate_span_duration(self, span: trace_api.Span) -> float: """ Calculate span duration in seconds. - + Args: span: OpenTelemetry span - + Returns: Duration in seconds """ import time - - if hasattr(span, 'start_time') and span.start_time: + + if hasattr(span, "start_time") and span.start_time: current_time_ns = time.time_ns() - return (current_time_ns - span.start_time) / 1_000_000_000 # ns to s + return ( + current_time_ns - span.start_time + ) / 1_000_000_000 # ns to s return 0.0 def _resolve_model_name( - self, - llm_response: LlmResponse, - request_key: str, - span: trace_api.Span + self, llm_response: LlmResponse, request_key: str, span: trace_api.Span ) -> str: """ Resolve model name with robust fallbacks. - + Args: llm_response: LLM response object request_key: Request key for stored models span: Current span - + 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') - + 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: + 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) Try span attributes if accessible - if not model_name and hasattr(span, 'attributes') and getattr(span, 'attributes'): + if ( + not model_name + and hasattr(span, "attributes") + and getattr(span, "attributes") + ): model_name = span.attributes.get("gen_ai.request.model") - + # 4) Parse from span name like "chat " - if not model_name and hasattr(span, 'name') and isinstance(span.name, str): + if ( + not model_name + and hasattr(span, "name") + and isinstance(span.name, str) + ): try: name = span.name if name.startswith("chat ") and len(name) > 5: model_name = name[5:] # Remove "chat " prefix except Exception: pass - + # 5) Final fallback if not model_name: model_name = "unknown" - + return model_name - def _convert_user_message_to_genai_format(self, user_message: types.Content) -> list: + def _convert_user_message_to_genai_format( + self, user_message: types.Content + ) -> list: """ Convert ADK user message to GenAI message format. - + Args: user_message: ADK Content object - + Returns: List of GenAI formatted messages """ input_messages = [] - if user_message and hasattr(user_message, 'role') and hasattr(user_message, 'parts'): - message = { - "role": user_message.role, - "parts": [] - } + if ( + user_message + and hasattr(user_message, "role") + and hasattr(user_message, "parts") + ): + message = {"role": user_message.role, "parts": []} for part in user_message.parts: - if hasattr(part, 'text'): - message["parts"].append({ - "type": "text", - "content": process_content(part.text) - }) + if hasattr(part, "text"): + message["parts"].append( + {"type": "text", "content": process_content(part.text)} + ) input_messages.append(message) return input_messages - diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py index 5a810de0..5490ba00 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_utils.py @@ -12,21 +12,28 @@ def should_capture_content() -> bool: """ Check if content capture is enabled via environment variable. - + Returns: True if OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT is set to "true" """ - return os.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "false").lower() == "true" + return ( + os.getenv( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "false" + ).lower() + == "true" + ) def get_max_content_length() -> Optional[int]: """ Get the configured maximum content length from environment variable. - + Returns: Maximum length in characters, or None if not set """ - limit_str = os.getenv('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') + limit_str = os.getenv( + "OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH" + ) if limit_str: try: return int(limit_str) @@ -38,54 +45,56 @@ def get_max_content_length() -> Optional[int]: def process_content(content: str) -> str: """ Process content with length limit and truncation. - + This replaces the ARMS SDK process_content() function with standard OTel behavior. - + Args: content: Content string to process - + Returns: Processed content with truncation marker if needed """ if not content: return "" - + if not should_capture_content(): return "" - + max_length = get_max_content_length() if max_length is None or len(content) <= max_length: return content - + # Add truncation marker truncation_marker = " [TRUNCATED]" effective_limit = max_length - len(truncation_marker) if effective_limit <= 0: return truncation_marker[:max_length] - + return content[:effective_limit] + truncation_marker -def safe_json_dumps(obj: Any, max_length: int = 1024, respect_env_limit: bool = False) -> str: +def safe_json_dumps( + obj: Any, max_length: int = 1024, respect_env_limit: bool = False +) -> str: """ Safely serialize an object to JSON with error handling and length limits. - + Args: obj: Object to serialize max_length: Maximum length of the resulting string (used as fallback) respect_env_limit: If True, use environment variable limit instead of max_length - + Returns: JSON string representation of the object """ try: json_str = json.dumps(obj, ensure_ascii=False, default=str) - + if respect_env_limit: json_str = process_content(json_str) elif len(json_str) > max_length: json_str = json_str[:max_length] + "...[truncated]" - + return json_str except Exception: fallback_str = str(obj) @@ -95,60 +104,64 @@ def safe_json_dumps(obj: Any, max_length: int = 1024, respect_env_limit: bool = return fallback_str[:max_length] -def safe_json_dumps_large(obj: Any, max_length: int = 1048576, respect_env_limit: bool = True) -> str: +def safe_json_dumps_large( + obj: Any, max_length: int = 1048576, respect_env_limit: bool = True +) -> str: """ Safely serialize large objects to JSON with extended length limits. - + This is specifically designed for content that may be large, such as LLM input/output messages. - + Args: obj: Object to serialize max_length: Maximum length (default 1MB, used as fallback) respect_env_limit: If True (default), use environment variable limit - + Returns: JSON string representation of the object """ return safe_json_dumps(obj, max_length, respect_env_limit) -def extract_content_safely(content: Any, max_length: int = 1024, respect_env_limit: bool = True) -> str: +def extract_content_safely( + content: Any, max_length: int = 1024, respect_env_limit: bool = True +) -> str: """ Safely extract text content from various ADK content types. - + Args: content: Content object (could be types.Content, string, etc.) max_length: Maximum length of extracted content (used as fallback) respect_env_limit: If True (default), use environment variable limit - + Returns: String representation of the content """ if not content: return "" - + try: # Handle Google genai types.Content objects - if hasattr(content, 'parts') and content.parts: + if hasattr(content, "parts") and content.parts: text_parts = [] for part in content.parts: - if hasattr(part, 'text') and part.text: + if hasattr(part, "text") and part.text: text_parts.append(part.text) content_str = "".join(text_parts) - elif hasattr(content, 'text'): + elif hasattr(content, "text"): content_str = content.text else: content_str = str(content) - + # Apply length limit with proper truncation handling if respect_env_limit: return process_content(content_str) elif len(content_str) > max_length: content_str = content_str[:max_length] + "...[truncated]" - + return content_str - + except Exception: fallback_str = str(content) if content else "" if respect_env_limit: @@ -160,13 +173,13 @@ def extract_content_safely(content: Any, max_length: int = 1024, respect_env_lim def safe_json_dumps_for_input_output(obj: Any) -> str: """ Safely serialize objects for input/output attributes with environment variable length limit. - + This function is specifically designed for input.value and output.value attributes and always respects the OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH environment variable. - + Args: obj: Object to serialize - + Returns: JSON string representation with proper truncation marker if needed """ @@ -176,34 +189,36 @@ def safe_json_dumps_for_input_output(obj: Any) -> str: def extract_content_safely_for_input_output(content: Any) -> str: """ Safely extract content for input/output attributes with environment variable length limit. - + This function is specifically designed for input/output content extraction and always respects the OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH environment variable. - + Args: content: Content object to extract text from - + Returns: String representation with proper truncation marker if needed """ - return extract_content_safely(content, max_length=1048576, respect_env_limit=True) + return extract_content_safely( + content, max_length=1048576, respect_env_limit=True + ) def extract_model_name(model_obj: Any) -> str: """ Extract model name from various model object types. - + Args: model_obj: Model object or model name string - + Returns: Model name string """ if isinstance(model_obj, str): return model_obj - elif hasattr(model_obj, 'model') and model_obj.model: + elif hasattr(model_obj, "model") and model_obj.model: return model_obj.model - elif hasattr(model_obj, 'name') and model_obj.name: + elif hasattr(model_obj, "name") and model_obj.name: return model_obj.name else: return "unknown" @@ -212,11 +227,11 @@ def extract_model_name(model_obj: Any) -> str: def is_slow_call(duration: float, threshold: float = 0.5) -> bool: """ Determine if a call should be considered slow. - + Args: duration: Duration in seconds threshold: Slow call threshold in seconds (default 500ms) - + Returns: True if call is considered slow """ @@ -226,10 +241,10 @@ def is_slow_call(duration: float, threshold: float = 0.5) -> bool: def get_error_attributes(error: Exception) -> dict: """ Extract error attributes from an exception. - + Args: error: Exception object - + Returns: Dictionary of error attributes """ @@ -238,4 +253,3 @@ def get_error_attributes(error: Exception) -> dict: # Note: error.message is non-standard, OTel recommends using span status # But we include it for debugging purposes } - 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 index e5d1b4c0..ad5a056b 100644 --- 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 @@ -18,4 +18,3 @@ _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 index 54d219ec..a5e99969 100644 --- 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 @@ -1,4 +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 index 13c52a1b..7eb68885 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/__init__.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/__init__.py @@ -1,2 +1 @@ """Tests for OpenTelemetry Google ADK Instrumentation.""" - diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py index af018a89..0df16c0e 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py @@ -9,19 +9,20 @@ against the latest OpenTelemetry GenAI semantic conventions. """ -import pytest import asyncio +from typing import Any, Dict, List from unittest.mock import Mock -from typing import Dict, List, Any, Optional -from opentelemetry import trace as trace_api -from opentelemetry.sdk import trace as trace_sdk -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from opentelemetry.sdk import metrics as metrics_sdk -from opentelemetry.sdk.metrics.export import InMemoryMetricReader +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"): @@ -39,7 +40,7 @@ def create_mock_callback_context(session_id="session_123", user_id="user_456"): 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 @@ -47,13 +48,13 @@ class OTelGenAIMetricsValidator: - 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 + "gen_ai.client.token.usage", # Histogram } - + # Non-standard metrics that should NOT be present NON_STANDARD_METRICS = { # ARMS-specific metrics @@ -69,46 +70,51 @@ class OTelGenAIMetricsValidator: "genai_calls_slow_count", "genai_llm_first_token_seconds", "genai_llm_usage_tokens", - "genai_avg_first_token_seconds" + "genai_avg_first_token_seconds", } - - def validate_metrics_data(self, metric_reader: InMemoryMetricReader) -> Dict[str, Any]: + + def validate_metrics_data( + self, metric_reader: InMemoryMetricReader + ) -> Dict[str, Any]: """Validate metrics data against OTel GenAI conventions.""" validation_result = { "metrics_found": set(), "non_standard_found": set(), "metric_validations": {}, "errors": [], - "warnings": [] + "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) - + # Check for non-standard metrics if metric.name in self.NON_STANDARD_METRICS: - validation_result["non_standard_found"].add(metric.name) - + validation_result["non_standard_found"].add( + metric.name + ) + # Validate individual metric - validation_result["metric_validations"][metric.name] = \ + validation_result["metric_validations"][metric.name] = ( self._validate_single_metric(metric) - + ) + # Check for non-standard metrics if validation_result["non_standard_found"]: validation_result["errors"].append( f"Found non-standard metrics: {validation_result['non_standard_found']}" ) - + return validation_result - + def _validate_single_metric(self, metric) -> Dict[str, Any]: """Validate a single metric's attributes and data.""" result = { @@ -116,140 +122,163 @@ def _validate_single_metric(self, metric) -> Dict[str, Any]: "type": type(metric.data).__name__, "data_points": [], "errors": [], - "warnings": [] + "warnings": [], } - + # Get data points data_points = [] - if hasattr(metric.data, '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) + 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]: + + 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": [] + "warnings": [], } - + # Extract attributes - if hasattr(data_point, 'attributes'): - result["attributes"] = dict(data_point.attributes) if data_point.attributes else {} - + if hasattr(data_point, "attributes"): + result["attributes"] = ( + dict(data_point.attributes) if data_point.attributes else {} + ) + # Extract value - if hasattr(data_point, 'sum'): + if hasattr(data_point, "sum"): result["value"] = data_point.sum - elif hasattr(data_point, 'count'): + 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") - + 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") - + result["errors"].append( + "Missing required attribute: gen_ai.provider.name" + ) + # Check for non-standard attributes non_standard_attrs = { - "callType", "callKind", "rpcType", "spanKind", # ARMS attributes - "modelName", "usageType", # Should be gen_ai.request.model, gen_ai.token.type - "session_id", "user_id" # High cardinality, should not be in metrics + "callType", + "callKind", + "rpcType", + "spanKind", # ARMS attributes + "modelName", + "usageType", # Should be gen_ai.request.model, gen_ai.token.type + "session_id", + "user_id", # High cardinality, should not be in metrics } - + for attr in non_standard_attrs: if attr in attributes: - result["errors"].append(f"Found non-standard attribute: {attr}") - + result["errors"].append( + f"Found non-standard attribute: {attr}" + ) + # 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}") - + 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.tracer_provider.add_span_processor( + SimpleSpanProcessor(self.span_exporter) + ) + self.metric_reader = InMemoryMetricReader() - self.meter_provider = metrics_sdk.MeterProvider(metric_readers=[self.metric_reader]) - + 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: + 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'): + 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 @@ -259,11 +288,11 @@ async def test_llm_metrics_with_standard_otel_attributes(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_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" @@ -271,89 +300,120 @@ async def test_llm_metrics_with_standard_otel_attributes(self): 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 + 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 + llm_response=mock_llm_response, ) - + # Validate metrics using InMemoryMetricReader - validation_result = self.validator.validate_metrics_data(self.metric_reader) - + validation_result = self.validator.validate_metrics_data( + self.metric_reader + ) + # Check for non-standard metrics - assert len(validation_result["non_standard_found"]) == 0, \ - f"Found non-standard metrics: {validation_result['non_standard_found']}" - + assert ( + len(validation_result["non_standard_found"]) == 0 + ), f"Found non-standard metrics: {validation_result['non_standard_found']}" + # 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" - + 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" - + 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" - + 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" + # Validate NO non-standard attributes assert "callType" not in duration_attrs, "Should NOT have callType" assert "spanKind" not in duration_attrs, "Should NOT have spanKind" assert "modelName" not in duration_attrs, "Should NOT have modelName" - assert "session_id" not in duration_attrs, "Should NOT have session_id (high cardinality)" - assert "user_id" not in duration_attrs, "Should NOT have user_id (high cardinality)" - + assert ( + "session_id" not in duration_attrs + ), "Should NOT have session_id (high cardinality)" + assert ( + "user_id" not in duration_attrs + ), "Should NOT have user_id (high cardinality)" + # 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)" - + 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" - + 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] - + 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" - + # Validate NO usageType attribute (should be gen_ai.token.type) input_attrs = dict(input_point.attributes) - assert "usageType" not in input_attrs, "Should NOT have usageType (use gen_ai.token.type)" + assert ( + "usageType" not in input_attrs + ), "Should NOT have usageType (use gen_ai.token.type)" @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 @@ -361,37 +421,39 @@ async def test_llm_metrics_with_error(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_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_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 + llm_request=mock_llm_request, ) - + await plugin.on_model_error_callback( callback_context=mock_callback_context, llm_request=mock_llm_request, - error=test_error + error=test_error, ) - + # Get metrics data - duration_points = self.get_metric_data_points("gen_ai.client.operation.duration") + 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" @@ -401,7 +463,7 @@ async def test_llm_metrics_with_error(self): async def test_agent_metrics_use_standard_attributes(self): """ Test that Agent metrics use standard OTel GenAI attributes. - + Validates: - gen_ai.operation.name = "invoke_agent" - Agent name mapped to gen_ai.request.model @@ -410,57 +472,65 @@ async def test_agent_metrics_use_standard_attributes(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_provider + meter_provider=self.meter_provider, ) - + plugin = self.instrumentor._plugin - + # Create mock agent mock_agent = Mock() mock_agent.name = "math_tutor" mock_agent.description = "Mathematical tutor agent" mock_agent.sub_agents = [] - + mock_callback_context = create_mock_callback_context() - + # Execute Agent callbacks await plugin.before_agent_callback( - agent=mock_agent, - callback_context=mock_callback_context + agent=mock_agent, callback_context=mock_callback_context ) - + await asyncio.sleep(0.01) - + await plugin.after_agent_callback( - agent=mock_agent, - callback_context=mock_callback_context + agent=mock_agent, callback_context=mock_callback_context ) - + # Get metrics data - duration_points = self.get_metric_data_points("gen_ai.client.operation.duration") + duration_points = self.get_metric_data_points( + "gen_ai.client.operation.duration" + ) assert len(duration_points) >= 1, "Should have agent duration metric" - + # Validate attributes agent_attrs = dict(duration_points[0].attributes) - assert agent_attrs.get("gen_ai.operation.name") == "invoke_agent", \ - "Should have gen_ai.operation.name = 'invoke_agent'" - assert "gen_ai.provider.name" in agent_attrs, "Should have provider name" - + assert ( + agent_attrs.get("gen_ai.operation.name") == "invoke_agent" + ), "Should have gen_ai.operation.name = 'invoke_agent'" + assert ( + "gen_ai.provider.name" in agent_attrs + ), "Should have provider name" + # Agent name should be in gen_ai.request.model - assert agent_attrs.get("gen_ai.request.model") == "math_tutor" or \ - "gen_ai.agent.name" in agent_attrs, \ - "Agent name should be in metrics" - + assert ( + agent_attrs.get("gen_ai.request.model") == "math_tutor" + or "gen_ai.agent.name" in agent_attrs + ), "Agent name should be in metrics" + # Validate NO ARMS attributes assert "spanKind" not in agent_attrs, "Should NOT have spanKind" - assert "session_id" not in agent_attrs, "Should NOT have high-cardinality session_id" - assert "user_id" not in agent_attrs, "Should NOT have high-cardinality user_id" + assert ( + "session_id" not in agent_attrs + ), "Should NOT have high-cardinality session_id" + assert ( + "user_id" not in agent_attrs + ), "Should NOT have high-cardinality user_id" @pytest.mark.asyncio async def test_tool_metrics_use_standard_attributes(self): """ Test that Tool metrics use standard OTel GenAI attributes. - + Validates: - gen_ai.operation.name = "execute_tool" - Tool name mapped to gen_ai.request.model @@ -469,57 +539,61 @@ async def test_tool_metrics_use_standard_attributes(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_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 callbacks await plugin.before_tool_callback( tool=mock_tool, tool_args=mock_tool_args, - tool_context=mock_tool_context + tool_context=mock_tool_context, ) - + await asyncio.sleep(0.01) - + await plugin.after_tool_callback( tool=mock_tool, tool_args=mock_tool_args, tool_context=mock_tool_context, - result=mock_result + result=mock_result, ) - + # Get metrics data - duration_points = self.get_metric_data_points("gen_ai.client.operation.duration") + duration_points = self.get_metric_data_points( + "gen_ai.client.operation.duration" + ) assert len(duration_points) >= 1, "Should have tool duration metric" - + # Validate attributes tool_attrs = dict(duration_points[0].attributes) - assert tool_attrs.get("gen_ai.operation.name") == "execute_tool", \ - "Should have gen_ai.operation.name = 'execute_tool'" + assert ( + tool_attrs.get("gen_ai.operation.name") == "execute_tool" + ), "Should have gen_ai.operation.name = 'execute_tool'" assert "gen_ai.provider.name" in tool_attrs - + # Tool name should be in metrics - assert tool_attrs.get("gen_ai.request.model") == "calculator" or \ - "gen_ai.tool.name" in tool_attrs, \ - "Tool name should be in metrics" + assert ( + tool_attrs.get("gen_ai.request.model") == "calculator" + or "gen_ai.tool.name" in tool_attrs + ), "Tool name should be in metrics" @pytest.mark.asyncio async def test_only_two_standard_metrics_recorded(self): """ Test that only 2 standard OTel GenAI metrics are recorded. - + Validates: - Only gen_ai.client.operation.duration - Only gen_ai.client.token.usage @@ -529,74 +603,93 @@ async def test_only_two_standard_metrics_recorded(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_provider + meter_provider=self.meter_provider, ) - + plugin = self.instrumentor._plugin - + # Execute various operations 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 + callback_context=mock_context, llm_request=mock_llm_request ) await plugin.after_model_callback( - callback_context=mock_context, - llm_response=mock_llm_response + callback_context=mock_context, llm_response=mock_llm_response ) - + # Agent call mock_agent = Mock() mock_agent.name = "agent1" mock_agent.sub_agents = [] - - await plugin.before_agent_callback(agent=mock_agent, callback_context=mock_context) - await plugin.after_agent_callback(agent=mock_agent, callback_context=mock_context) - + + await plugin.before_agent_callback( + agent=mock_agent, callback_context=mock_context + ) + await plugin.after_agent_callback( + agent=mock_agent, callback_context=mock_context + ) + # Validate metrics - validation_result = self.validator.validate_metrics_data(self.metric_reader) - + 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}" - + 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}" + # Should have NO non-standard metrics - assert len(validation_result["non_standard_found"]) == 0, \ - f"Should have NO non-standard metrics, found: {validation_result['non_standard_found']}" - + assert ( + len(validation_result["non_standard_found"]) == 0 + ), f"Should have NO non-standard metrics, found: {validation_result['non_standard_found']}" + # Explicitly check ARMS metrics are NOT present arms_metrics = { - "calls_count", "calls_duration_seconds", "call_error_count", - "llm_usage_tokens", "llm_first_token_seconds" + "calls_count", + "calls_duration_seconds", + "call_error_count", + "llm_usage_tokens", + "llm_first_token_seconds", } found_arms_metrics = validation_result["metrics_found"] & arms_metrics - assert len(found_arms_metrics) == 0, \ - f"Should NOT have ARMS metrics, found: {found_arms_metrics}" - + assert ( + len(found_arms_metrics) == 0 + ), f"Should NOT have ARMS metrics, found: {found_arms_metrics}" + # Explicitly check custom GenAI metrics are NOT present custom_genai_metrics = { - "genai_calls_count", "genai_calls_duration_seconds", - "genai_calls_error_count", "genai_calls_slow_count", - "genai_llm_first_token_seconds", "genai_llm_usage_tokens" + "genai_calls_count", + "genai_calls_duration_seconds", + "genai_calls_error_count", + "genai_calls_slow_count", + "genai_llm_first_token_seconds", + "genai_llm_usage_tokens", } - found_custom_metrics = validation_result["metrics_found"] & custom_genai_metrics - assert len(found_custom_metrics) == 0, \ - f"Should NOT have custom GenAI metrics, found: {found_custom_metrics}" + found_custom_metrics = ( + validation_result["metrics_found"] & custom_genai_metrics + ) + assert ( + len(found_custom_metrics) == 0 + ), f"Should NOT have custom GenAI metrics, found: {found_custom_metrics}" # Run tests 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 index c127bd56..1327adcc 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_plugin_integration.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_plugin_integration.py @@ -9,19 +9,20 @@ against the latest OpenTelemetry GenAI semantic conventions. """ -import pytest -import asyncio +from typing import Any, Dict from unittest.mock import Mock -from typing import Dict, List, Any + +import pytest from opentelemetry import trace as trace_api -from opentelemetry.sdk import trace as trace_sdk -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +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.instrumentation.google_adk import GoogleAdkInstrumentor +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"): @@ -39,7 +40,7 @@ def create_mock_callback_context(session_id="session_123", user_id="user_456"): 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) @@ -49,27 +50,31 @@ class OTelGenAISpanValidator: - 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"}, + "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" - } + "gen_ai.usage.output_tokens", + }, }, "invoke_agent": { "required": {"gen_ai.operation.name"}, - "recommended": {"gen_ai.agent.name", "gen_ai.agent.description"} + "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"} - } + "recommended": {"gen_ai.tool.description"}, + }, } - + # Non-standard attributes that should NOT be present NON_STANDARD_ATTRIBUTES = { "gen_ai.span.kind", # Use gen_ai.operation.name instead @@ -83,7 +88,7 @@ class OTelGenAISpanValidator: "gen_ai.input.message_count", # Non-standard "gen_ai.output.message_count", # Non-standard } - + def validate_span(self, span, expected_operation: str) -> Dict[str, Any]: """Validate a single span's attributes against OTel GenAI conventions.""" validation_result = { @@ -93,47 +98,51 @@ def validate_span(self, span, expected_operation: str) -> Dict[str, Any]: "warnings": [], "missing_required": [], "missing_recommended": [], - "non_standard_found": [] + "non_standard_found": [], } - - attributes = getattr(span, 'attributes', {}) or {} - + + 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") + 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}'" ) - + # Check for non-standard attributes for attr_key in attributes.keys(): if attr_key in self.NON_STANDARD_ATTRIBUTES: validation_result["non_standard_found"].append(attr_key) - + # Validate required and recommended attributes if expected_operation in self.REQUIRED_ATTRIBUTES_BY_OPERATION: - requirements = self.REQUIRED_ATTRIBUTES_BY_OPERATION[expected_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"] @@ -141,15 +150,17 @@ def _validate_attribute_formats(self, attributes: Dict, result: Dict): 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" + "gen_ai.usage.output_tokens", ] for attr in numeric_attrs: - if attr in attributes and not isinstance(attributes[attr], (int, float)): + if attr in attributes and not isinstance( + attributes[attr], (int, float) + ): result["errors"].append( f"Attribute {attr} should be numeric, got {type(attributes[attr])}" ) @@ -157,46 +168,50 @@ def _validate_attribute_formats(self, attributes: Dict, result: Dict): 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.tracer_provider.add_span_processor( + SimpleSpanProcessor(self.span_exporter) + ) + self.metric_reader = InMemoryMetricReader() - self.meter_provider = metrics_sdk.MeterProvider(metric_readers=[self.metric_reader]) - + 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: + 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 @@ -206,11 +221,11 @@ async def test_llm_span_attributes_semantic_conventions(self): # Instrument the plugin self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_provider + meter_provider=self.meter_provider, ) - + plugin = self.instrumentor._plugin - + # Create mock LLM request mock_llm_request = Mock() mock_llm_request.model = "gemini-pro" @@ -221,7 +236,7 @@ async def test_llm_span_attributes_semantic_conventions(self): 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" @@ -230,81 +245,95 @@ async def test_llm_span_attributes_semantic_conventions(self): 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") - + + 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 + llm_request=mock_llm_request, ) await plugin.after_model_callback( callback_context=mock_callback_context, - llm_response=mock_llm_response + 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}'" - + 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']}" - + assert ( + len(validation_result["errors"]) == 0 + ), f"Validation errors: {validation_result['errors']}" + # Check for non-standard attributes - assert len(validation_result["non_standard_found"]) == 0, \ - f"Found non-standard attributes: {validation_result['non_standard_found']}" - + assert ( + len(validation_result["non_standard_found"]) == 0 + ), f"Found non-standard attributes: {validation_result['non_standard_found']}" + # 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.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 ( + "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 ( + "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)" + 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" - + assert isinstance( + finish_reasons, (list, tuple) + ), "gen_ai.response.finish_reasons should be array" + # Validate NO non-standard attributes - assert "gen_ai.span.kind" not in attributes, \ - "Should NOT have gen_ai.span.kind (non-standard)" - assert "gen_ai.system" not in attributes, \ - "Should NOT have gen_ai.system (use gen_ai.provider.name)" - assert "gen_ai.framework" not in attributes, \ - "Should NOT have gen_ai.framework (non-standard)" + assert ( + "gen_ai.span.kind" not in attributes + ), "Should NOT have gen_ai.span.kind (non-standard)" + assert ( + "gen_ai.system" not in attributes + ), "Should NOT have gen_ai.system (use gen_ai.provider.name)" + assert ( + "gen_ai.framework" not in attributes + ), "Should NOT have gen_ai.framework (non-standard)" @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" @@ -313,58 +342,65 @@ async def test_agent_span_attributes_semantic_conventions(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_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") - + + 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 + agent=mock_agent, callback_context=mock_callback_context ) await plugin.after_agent_callback( - agent=mock_agent, - callback_context=mock_callback_context + 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}'" - + 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']}" - + 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" + 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" @@ -374,59 +410,67 @@ async def test_tool_span_attributes_semantic_conventions(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_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 + 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 + 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}'" - + 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" - + 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']}" - + 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" + assert ( + attributes.get("gen_ai.tool.description") + == "Mathematical calculator" + ) @pytest.mark.asyncio async def test_runner_span_attributes(self): @@ -434,11 +478,11 @@ async def test_runner_span_attributes(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_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" @@ -446,21 +490,26 @@ async def test_runner_span_attributes(self): 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) - + 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}'" - + 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" @@ -471,7 +520,7 @@ async def test_runner_span_attributes(self): 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) @@ -480,50 +529,53 @@ async def test_error_handling_attributes(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_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_callback_context = create_mock_callback_context("session_err", "user_err") - + + 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 + llm_request=mock_llm_request, ) await plugin.on_model_error_callback( callback_context=mock_callback_context, llm_request=mock_llm_request, - error=test_error + 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" - + 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 "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 @@ -531,7 +583,7 @@ async def test_error_handling_attributes(self): 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 @@ -540,11 +592,11 @@ async def test_metrics_recorded_with_correct_dimensions(self): # Instrument self.instrumentor.instrument( tracer_provider=self.tracer_provider, - meter_provider=self.meter_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" @@ -552,31 +604,31 @@ async def test_metrics_recorded_with_correct_dimensions(self): 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 + llm_request=mock_llm_request, ) await plugin.after_model_callback( callback_context=mock_callback_context, - llm_response=mock_llm_response + 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 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_utils.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_utils.py index 93185612..9bfa739e 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_utils.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_utils.py @@ -3,166 +3,196 @@ """ import os + import pytest + from opentelemetry.instrumentation.google_adk.internal._utils import ( - should_capture_content, - get_max_content_length, - process_content, - safe_json_dumps, - safe_json_dumps_large, extract_content_safely, extract_model_name, + get_error_attributes, + get_max_content_length, is_slow_call, - get_error_attributes + process_content, + safe_json_dumps, + should_capture_content, ) class TestContentCapture: """Tests for content capture utilities.""" - + def test_should_capture_content_default_false(self): """Test that content capture is disabled by default.""" # Clear environment variable - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT', None) + os.environ.pop( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", None + ) # Default is False unless explicitly enabled assert should_capture_content() is False - + def test_should_capture_content_enabled(self): """Test that content capture can be explicitly enabled.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) assert should_capture_content() is True - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + def test_should_capture_content_disabled(self): """Test that content capture can be disabled.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'false' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "false" + ) assert should_capture_content() is False - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + def test_get_max_length_default(self): """Test default max length.""" - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH', None) + os.environ.pop( + "OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH", None + ) # Return value is Optional[int], so None is valid max_len = get_max_content_length() assert max_len is None or max_len > 0 - + def test_get_max_length_custom(self): """Test custom max length.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH'] = '1000' + os.environ["OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH"] = ( + "1000" + ) assert get_max_content_length() == 1000 - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH") + def test_get_max_length_invalid(self): """Test invalid max length returns None.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH'] = 'invalid' + os.environ["OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH"] = ( + "invalid" + ) assert get_max_content_length() is None - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH") + def test_process_content_short_string(self): """Test processing short content.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) content = "Hello, world!" result = process_content(content) assert result == content - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + def test_process_content_long_string(self): """Test processing long content - may be truncated if max length set.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' - os.environ['OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH'] = '1000' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) + os.environ["OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH"] = ( + "1000" + ) content = "A" * 10000 result = process_content(content) # Result should be truncated assert isinstance(result, str) assert len(result) <= 1000 assert "[TRUNCATED]" in result - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH") + def test_process_content_when_disabled(self): """Test processing content when capture is disabled.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'false' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "false" + ) content = "B" * 200 result = process_content(content) # Should return empty string when disabled assert result == "" - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + def test_process_content_with_custom_max_length(self): """Test processing content with custom max length.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' - os.environ['OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH'] = '100' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) + os.environ["OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH"] = ( + "100" + ) content = "B" * 200 result = process_content(content) assert len(result) <= 100 - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH') + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_MESSAGE_CONTENT_MAX_LENGTH") class TestJsonUtils: """Tests for JSON utility functions.""" - + def test_safe_json_dumps_basic(self): """Test basic JSON serialization.""" data = {"key": "value", "number": 42} result = safe_json_dumps(data) assert '"key": "value"' in result assert '"number": 42' in result - + def test_safe_json_dumps_nested(self): """Test nested JSON serialization.""" - data = { - "outer": { - "inner": ["a", "b", "c"] - } - } + data = {"outer": {"inner": ["a", "b", "c"]}} result = safe_json_dumps(data) assert "outer" in result assert "inner" in result - + def test_safe_json_dumps_error_fallback(self): """Test fallback for non-serializable objects.""" + class NonSerializable: pass - + data = {"obj": NonSerializable()} result = safe_json_dumps(data) # Should return some string representation without crashing assert isinstance(result, str) - + def test_extract_content_safely_string(self): """Test extracting string content.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) result = extract_content_safely("test string") assert result == "test string" - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + def test_extract_content_safely_dict(self): """Test extracting dict content.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) data = {"message": "test"} result = extract_content_safely(data) assert "message" in result assert "test" in result - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + def test_extract_content_safely_list(self): """Test extracting list content.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "true" + ) data = ["item1", "item2"] result = extract_content_safely(data) assert "item1" in result assert "item2" in result - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + def test_extract_content_safely_when_disabled(self): """Test extracting content when capture is disabled.""" - os.environ['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'false' + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "false" + ) result = extract_content_safely("test string") # Should return empty string when capture is disabled assert result == "" - os.environ.pop('OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT') - + os.environ.pop("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") + def test_extract_content_safely_none(self): """Test extracting None content.""" result = extract_content_safely(None) @@ -171,32 +201,33 @@ def test_extract_content_safely_none(self): class TestModelUtils: """Tests for model-related utility functions.""" - + def test_extract_model_name_simple(self): """Test extracting simple model name.""" result = extract_model_name("gpt-4") assert result == "gpt-4" - + def test_extract_model_name_with_provider(self): """Test extracting model name from full path.""" # extract_model_name returns the string as-is if it's a string result = extract_model_name("providers/google/models/gemini-pro") assert result == "providers/google/models/gemini-pro" - + def test_extract_model_name_empty(self): """Test extracting empty model name.""" # Empty string is still a string, so it returns as-is result = extract_model_name("") assert result == "" - + def test_extract_model_name_none(self): """Test extracting None model name.""" result = extract_model_name(None) assert result == "unknown" - + def test_extract_model_name_from_object(self): """Test extracting model name from object with model attribute.""" from unittest.mock import Mock + mock_obj = Mock() mock_obj.model = "gemini-pro" result = extract_model_name(mock_obj) @@ -205,17 +236,17 @@ def test_extract_model_name_from_object(self): class TestSpanUtils: """Tests for span-related utility functions.""" - + def test_is_slow_call_threshold_exceeded(self): """Test slow call detection when threshold exceeded.""" # 2 seconds with 1 second threshold assert is_slow_call(2.0, threshold=1.0) is True - + def test_is_slow_call_threshold_not_exceeded(self): """Test slow call detection when threshold not exceeded.""" # 0.5 seconds with 1 second threshold assert is_slow_call(0.5, threshold=1.0) is False - + def test_is_slow_call_default_threshold(self): """Test slow call detection with default threshold.""" # Assuming default threshold is 0.5 seconds @@ -227,31 +258,32 @@ def test_is_slow_call_default_threshold(self): class TestErrorUtils: """Tests for error handling utilities.""" - + def test_get_error_attributes_basic(self): """Test getting error attributes for basic exception.""" error = ValueError("test error") attrs = get_error_attributes(error) - + assert attrs["error.type"] == "ValueError" - + def test_get_error_attributes_timeout(self): """Test getting error attributes for timeout.""" error = TimeoutError("Operation timed out") attrs = get_error_attributes(error) - + assert attrs["error.type"] == "TimeoutError" - + def test_get_error_attributes_custom_exception(self): """Test getting error attributes for custom exception.""" + class CustomError(Exception): pass - + error = CustomError("custom message") attrs = get_error_attributes(error) - + assert attrs["error.type"] == "CustomError" - + def test_get_error_attributes_none(self): """Test getting error attributes when None is passed.""" # Even None has a type, so error.type will be 'NoneType' From 59a30190533eb3f3281a40fc6233ced7d319d167 Mon Sep 17 00:00:00 2001 From: Huxing Zhang Date: Fri, 28 Nov 2025 09:13:21 +0800 Subject: [PATCH 11/11] docs: Revise README for Google ADK instrumentation Change-Id: If5dbb7ad8f67a9e51ff95398b79f59982020bf5a Co-developed-by: Cursor --- .../README.md | 175 ++++++++++++++---- 1 file changed, 134 insertions(+), 41 deletions(-) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md index 17e503f7..3a2b2089 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md @@ -1,62 +1,125 @@ # OpenTelemetry Google ADK Instrumentation -Google ADK (Agent Development Kit) Python Agent provides observability for Google ADK applications. -This document provides examples of usage and results in the Google ADK instrumentation. -For details on usage and installation of LoongSuite and Jaeger, please refer to [LoongSuite Documentation](https://github.com/alibaba/loongsuite-python-agent/blob/main/README.md). +[![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 -# Open Telemetry +# OpenTelemetry Core pip install opentelemetry-distro opentelemetry-exporter-otlp opentelemetry-bootstrap -a install -# google-adk +# 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-genai/opentelemetry-instrumentation-google-adk +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/simple_adk_app.py) that demonstrates agent interactions +- A [Google ADK application](examples/main.py) that demonstrates agent interactions with multiple tools ### Running the Demo -#### Option 1: Using OpenTelemetry +> **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 + --traces_exporter console \ + --service_name demo-google-adk \ + python examples/main.py ``` #### Option 2: Using Loongsuite ```bash -export DASHSCOPE_API_KEY=xxxx +# 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 + --traces_exporter console \ + --service_name demo-google-adk \ + python examples/main.py ``` -### Results +#### 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: -```bash +#### Tool Execution Span Example + +```json { "name": "execute_tool get_current_time", "context": { @@ -73,15 +136,10 @@ The instrumentation will generate traces showing the Google ADK operations: }, "attributes": { "gen_ai.operation.name": "execute_tool", - "gen_ai.tool.description": "xxx", "gen_ai.tool.name": "get_current_time", - "gen_ai.tool.type": "FunctionTool", - "gcp.vertex.agent.llm_request": "{}", - "gcp.vertex.agent.llm_response": "{}", - "gcp.vertex.agent.tool_call_args": "{}", - "gen_ai.tool.call.id": "xxx", - "gcp.vertex.agent.event_id": "xxxx", - "gcp.vertex.agent.tool_response": "xxx" + "gen_ai.tool.description": "xxx", + "input.value": "{xxx}", + "output.value": "{xxx}" }, "events": [], "links": [], @@ -90,30 +148,47 @@ The instrumentation will generate traces showing the Google ADK operations: "telemetry.sdk.language": "python", "telemetry.sdk.name": "opentelemetry", "telemetry.sdk.version": "1.37.0", - "service.name": "demo-google-adk", - "telemetry.auto.version": "0.59b0" + "service.name": "demo-google-adk" }, "schema_url": "" } } ``` -After [setting up jaeger](https://www.jaegertracing.io/docs/1.6/getting-started/) and exporting data to it by following these commands: +#### LLM Chat Span Example -```bash -export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true - -loongsuite-instrument \ ---exporter_otlp_protocol grpc \ ---traces_exporter otlp \ ---exporter_otlp_insecure true \ ---exporter_otlp_endpoint YOUR-END-POINT \ -python examples/main.py +```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 + } +} ``` -You can see traces on the jaeger UI: +#### 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 @@ -124,6 +199,7 @@ The following environment variables can be used to configure the Google ADK inst | 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 @@ -225,11 +301,28 @@ This instrumentation follows the OpenTelemetry GenAI semantic conventions: ### Common Issues -1. **Module Import Error**: If you encounter `No module named 'google.adk.runners'`, ensure that `google-adk` is properly installed. +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 -2. **Instrumentation Not Working**: Check that the instrumentation is enabled and the Google ADK application is using the `Runner` class. +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 -3. **Missing Traces**: Verify that the OpenTelemetry exporters are properly configured. ## References