diff --git a/CHANGELOG-loongsuite.md b/CHANGELOG-loongsuite.md index 15610be6a..b819eab0e 100644 --- a/CHANGELOG-loongsuite.md +++ b/CHANGELOG-loongsuite.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Adopt upstream semantic conventions for LoongSuite instrumentation packages. + ## Version 0.5.0 (2026-05-11) ### Fixed diff --git a/instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/patch.py b/instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/patch.py index 481277d69..0cc21ad39 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/patch.py +++ b/instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/patch.py @@ -173,7 +173,7 @@ def handle_response( | None, ) -> None: attributes = ( - get_server_attributes(instance.api_endpoint) # type: ignore[reportUnknownMemberType] + get_server_attributes(instance.api_endpoint) | request_attributes | get_genai_response_attributes(response) ) @@ -257,7 +257,7 @@ def _with_default_instrumentation( kwargs: Any, ): params = _extract_params(*args, **kwargs) - api_endpoint: str = instance.api_endpoint # type: ignore[reportUnknownMemberType] + api_endpoint: str = instance.api_endpoint span_attributes = { **get_genai_request_attributes(False, params), **get_server_attributes(api_endpoint), diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/patch.py b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/patch.py index 7fb2cd58c..d85c6f4a6 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/patch.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/patch.py @@ -401,8 +401,11 @@ async def wrap_tool_call(wrapped, instance, args, kwargs, handler): matched_skill = _match_skill_for_tool(instance, tool_args) # Create invocation object with all tool data + # NOTE: tool_type is set to "function" as agentscope currently only + # supports function-type tools. Update when other types are supported. invocation = ExecuteToolInvocation( tool_name=tool_name, + tool_type="function", tool_call_id=tool_id, tool_description=tool_description, tool_call_arguments=tool_args, diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/tests/test_skill_detection.py b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/tests/test_skill_detection.py index 747fc8c46..8cb2c20aa 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/tests/test_skill_detection.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/tests/test_skill_detection.py @@ -283,6 +283,7 @@ async def fake_tool_generator(): invocation = ExecuteToolInvocation( tool_name="read_file", + tool_type="function", skill_name="news", skill_id="workspace:default:news", skill_description="Latest news", diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/__init__.py index 7e34fa169..ecab2358d 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/__init__.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/__init__.py @@ -102,15 +102,14 @@ def _instrument(self, **kwargs: Any) -> None: wrap_function_wrapper( module="claude_agent_sdk", name="ClaudeSDKClient.__init__", - wrapper=lambda wrapped, - instance, - args, - kwargs: wrap_claude_client_init( - wrapped, - instance, - args, - kwargs, - handler=ClaudeAgentSDKInstrumentor._handler, + wrapper=lambda wrapped, instance, args, kwargs: ( + wrap_claude_client_init( + wrapped, + instance, + args, + kwargs, + handler=ClaudeAgentSDKInstrumentor._handler, + ) ), ) except Exception as e: @@ -122,15 +121,14 @@ def _instrument(self, **kwargs: Any) -> None: wrap_function_wrapper( module="claude_agent_sdk", name="ClaudeSDKClient.query", - wrapper=lambda wrapped, - instance, - args, - kwargs: wrap_claude_client_query( - wrapped, - instance, - args, - kwargs, - handler=ClaudeAgentSDKInstrumentor._handler, + wrapper=lambda wrapped, instance, args, kwargs: ( + wrap_claude_client_query( + wrapped, + instance, + args, + kwargs, + handler=ClaudeAgentSDKInstrumentor._handler, + ) ), ) except Exception as e: @@ -140,15 +138,14 @@ def _instrument(self, **kwargs: Any) -> None: wrap_function_wrapper( module="claude_agent_sdk", name="ClaudeSDKClient.receive_response", - wrapper=lambda wrapped, - instance, - args, - kwargs: wrap_claude_client_receive_response( - wrapped, - instance, - args, - kwargs, - handler=ClaudeAgentSDKInstrumentor._handler, + wrapper=lambda wrapped, instance, args, kwargs: ( + wrap_claude_client_receive_response( + wrapped, + instance, + args, + kwargs, + handler=ClaudeAgentSDKInstrumentor._handler, + ) ), ) except Exception as e: diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py index 8477b6950..2f0b509eb 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py @@ -159,6 +159,7 @@ def _create_tool_spans_from_message( try: tool_invocation = ExecuteToolInvocation( tool_name=tool_name, + tool_type="function", tool_call_id=tool_use_id, tool_call_arguments=tool_input, tool_description=tool_name, diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claw-eval/src/opentelemetry/instrumentation/claw_eval/internal/wrappers.py b/instrumentation-loongsuite/loongsuite-instrumentation-claw-eval/src/opentelemetry/instrumentation/claw_eval/internal/wrappers.py index 4f8b246ea..f9c0d82df 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-claw-eval/src/opentelemetry/instrumentation/claw_eval/internal/wrappers.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claw-eval/src/opentelemetry/instrumentation/claw_eval/internal/wrappers.py @@ -62,6 +62,38 @@ # string instead of reading it from ``gen_ai_attributes``. GEN_AI_TOOL_DEFINITIONS = "gen_ai.tool.definitions" + +def _infer_provider_name(provider: Any) -> str: + """Infer gen_ai.provider.name from the provider instance.""" + if provider is None: + return "claw-eval" + # Try provider_name attribute + pn = getattr(provider, "provider_name", None) + if pn: + return str(pn) + # Infer from class name + cls_name = type(provider).__name__.lower() + if "openai" in cls_name: + return "openai" + if "anthropic" in cls_name: + return "anthropic" + if "dashscope" in cls_name: + return "dashscope" + if "gemini" in cls_name or "google" in cls_name: + return "google" + # Infer from model_id + model_id = str(getattr(provider, "model_id", "") or "") + if model_id.startswith(("gpt-", "o1-", "o3-", "chatgpt-")): + return "openai" + if model_id.startswith(("claude-",)): + return "anthropic" + if model_id.startswith(("qwen",)): + return "dashscope" + if model_id.startswith(("gemini",)): + return "google" + return "unknown" + + # --------------------------------------------------------------------------- # ContextVars for STEP lifecycle & compact-depth tracking # --------------------------------------------------------------------------- @@ -514,6 +546,10 @@ def __call__(self, wrapped, instance, args, kwargs): span.set_attribute(GenAI.GEN_AI_AGENT_NAME, "claw-eval") span.set_attribute("claw_eval.task_id", str(task_id)) + # Set gen_ai.provider.name (Required attribute) + _provider_name = _infer_provider_name(provider) + span.set_attribute(GenAI.GEN_AI_PROVIDER_NAME, _provider_name) + model_id = "" if provider is not None: model_id = str(getattr(provider, "model_id", "") or "") diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/patch/embedding.py b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/patch/embedding.py index d09975819..43502d72e 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/patch/embedding.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/patch/embedding.py @@ -52,6 +52,8 @@ def wrap_text_embedding_call(wrapped, instance, args, kwargs, handler=None): # Create embedding invocation object invocation = EmbeddingInvocation(request_model=model) invocation.provider = "dashscope" + invocation.server_address = "dashscope.aliyuncs.com" + invocation.server_port = 443 # Extract parameters from kwargs or kwargs["parameters"] dict parameters = kwargs.get("parameters", {}) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/common.py b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/common.py index 32f9b4f71..6ee9819a6 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/common.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/common.py @@ -88,6 +88,48 @@ def _extract_usage(response: Any) -> tuple[Optional[int], Optional[int]]: return None, None +def _extract_cache_tokens( + response: Any, +) -> tuple[Optional[int], Optional[int]]: + """Extract cache token usage from DashScope response. + + Args: + response: DashScope response object + + Returns: + Tuple of (cache_creation_input_tokens, cache_read_input_tokens) + """ + if not response: + return None, None + + try: + usage = getattr(response, "usage", None) + if not usage: + return None, None + + # DashScope may report cache tokens in various fields + cache_creation = getattr( + usage, "cache_creation_input_tokens", None + ) or getattr(usage, "cache_creation_tokens", None) + cache_read = ( + getattr(usage, "cache_read_input_tokens", None) + or getattr(usage, "cache_read_tokens", None) + or getattr(usage, "prompt_cache_hit_tokens", None) + ) + + # Also check prompt_tokens_details (OpenAI-compatible format) + prompt_details = getattr(usage, "prompt_tokens_details", None) + if prompt_details and cache_read is None: + cache_read = getattr(prompt_details, "cached_tokens", None) + + return ( + cache_creation if cache_creation and cache_creation > 0 else None, + cache_read if cache_read and cache_read > 0 else None, + ) + except (KeyError, AttributeError): + return None, None + + def _extract_task_id(task: Any) -> Optional[str]: """Extract task_id from task parameter (can be str or Response object). diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/generation.py b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/generation.py index c278842c4..7fe5bcc1f 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/generation.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/src/opentelemetry/instrumentation/dashscope/utils/generation.py @@ -469,6 +469,10 @@ def _create_invocation_from_generation( invocation = LLMInvocation(request_model=request_model) invocation.provider = "dashscope" + # DashScope SDK always targets dashscope.aliyuncs.com; if custom endpoint + # support is added in the future, extract from instance/env instead. + invocation.server_address = "dashscope.aliyuncs.com" + invocation.server_port = 443 invocation.input_messages = _extract_input_messages(kwargs) # Extract tool definitions and convert to FunctionToolDefinition objects @@ -568,6 +572,15 @@ def _update_invocation_from_response( invocation.input_tokens = input_tokens invocation.output_tokens = output_tokens + # Extract cache token usage + from ..utils.common import _extract_cache_tokens # noqa: PLC0415 + + cache_creation, cache_read = _extract_cache_tokens(response) + if cache_creation is not None: + invocation.usage_cache_creation_input_tokens = cache_creation + if cache_read is not None: + invocation.usage_cache_read_input_tokens = cache_read + # Extract response model name (if available) response_model = _safe_get(response, "model") if response_model: diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/test_embedding.py b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/test_embedding.py index 898516798..ac1bb50f4 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/test_embedding.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/test_embedding.py @@ -141,7 +141,7 @@ def test_text_embedding_basic(instrument, span_exporter): """Test basic text embedding call.""" response = TextEmbedding.call( - model="text-embedding-v1", input="Hello, world!" + model="text-embedding-v4", input="Hello, world!" ) assert response is not None @@ -165,7 +165,7 @@ def test_text_embedding_basic(instrument, span_exporter): # Assert all span attributes _assert_embedding_span_attributes( span, - request_model="text-embedding-v1", + request_model="text-embedding-v4", response=response, input_tokens=input_tokens, ) @@ -178,7 +178,7 @@ def test_text_embedding_batch(instrument, span_exporter): """Test text embedding with batch input.""" response = TextEmbedding.call( - model="text-embedding-v1", input=["Hello", "World"] + model="text-embedding-v4", input=["Hello", "World"] ) assert response is not None @@ -202,7 +202,7 @@ def test_text_embedding_batch(instrument, span_exporter): # Assert all span attributes _assert_embedding_span_attributes( span, - request_model="text-embedding-v1", + request_model="text-embedding-v4", response=response, input_tokens=input_tokens, ) @@ -215,7 +215,7 @@ def test_text_embedding_with_text_type(instrument, span_exporter): """Test text embedding with text_type parameter.""" response = TextEmbedding.call( - model="text-embedding-v1", + model="text-embedding-v4", input="What is machine learning?", text_type="query", ) @@ -241,7 +241,7 @@ def test_text_embedding_with_text_type(instrument, span_exporter): # Assert all span attributes _assert_embedding_span_attributes( span, - request_model="text-embedding-v1", + request_model="text-embedding-v4", response=response, input_tokens=input_tokens, ) @@ -254,7 +254,7 @@ def test_text_embedding_with_dimension(instrument, span_exporter): """Test text embedding with dimension parameter.""" response = TextEmbedding.call( - model="text-embedding-v1", + model="text-embedding-v4", input="What is machine learning?", dimension=512, ) @@ -280,7 +280,7 @@ def test_text_embedding_with_dimension(instrument, span_exporter): # Assert all span attributes including dimension_count _assert_embedding_span_attributes( span, - request_model="text-embedding-v1", + request_model="text-embedding-v4", response=response, input_tokens=input_tokens, dimension_count=512, # Should be captured from request 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 ea331c3fc..30ce6961a 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 @@ -119,6 +119,7 @@ async def before_run_callback( invocation = InvokeAgentInvocation( provider="google_adk", agent_name=invocation_context.app_name, + agent_id=invocation_context.app_name, ) # Set conversation_id if available @@ -275,6 +276,7 @@ async def before_agent_callback( invocation = InvokeAgentInvocation( provider="google_adk", agent_name=agent.name, + agent_id=agent.name, ) # Set agent attributes @@ -508,6 +510,7 @@ async def before_tool_callback( # Create invocation object invocation = ExecuteToolInvocation( tool_name=tool.name, + tool_type="function", provider="google_adk", ) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py index 80459e88f..7f86855ee 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py @@ -571,6 +571,7 @@ def create_agent_invocation( invocation = InvokeAgentInvocation( provider=_HERMES_AGENT_SYSTEM, agent_name="Hermes", + agent_id="Hermes", conversation_id=getattr(instance, "session_id", None), request_model=getattr(instance, "model", None), input_messages=[ @@ -609,6 +610,30 @@ def should_create_entry_for_agent(instance: Any) -> bool: return bool(_entry_platform(instance)) +def _extract_server_info(instance: Any) -> tuple[str | None, int | None]: + """Extract server address and port from instance base_url.""" + base_url = getattr(instance, "base_url", None) + if not base_url: + return None, None + base_url_str = str(base_url).rstrip("/") + # Handle schemeless URLs by prepending "//" for urlparse. + if "://" not in base_url_str: + base_url_str = "//" + base_url_str + try: + from urllib.parse import urlparse # noqa: PLC0415 + + parsed = urlparse(base_url_str) + address = parsed.hostname + if not address: + return None, None + port = parsed.port + if port is None: + port = 443 if parsed.scheme == "https" else 80 + return address, port + except Exception: + return None, None + + def create_llm_invocation(instance: Any, api_kwargs: Any) -> LLMInvocation: if not isinstance(api_kwargs, dict): api_kwargs = {} @@ -620,6 +645,8 @@ def create_llm_invocation(instance: Any, api_kwargs: Any) -> LLMInvocation: if max_tokens is None: max_tokens = api_kwargs.get("max_output_tokens") + server_address, server_port = _extract_server_info(instance) + invocation = LLMInvocation( provider=provider_name(instance), request_model=request_model or None, @@ -633,6 +660,8 @@ def create_llm_invocation(instance: Any, api_kwargs: Any) -> LLMInvocation: presence_penalty=api_kwargs.get("presence_penalty"), seed=api_kwargs.get("seed"), stop_sequences=api_kwargs.get("stop"), + server_address=server_address, + server_port=server_port, ) return invocation @@ -669,6 +698,29 @@ def update_llm_invocation_from_response( if output_tokens > 0: invocation.output_tokens = output_tokens + # Extract cache token usage + usage = getattr(response, "usage", None) + if usage is not None: + # OpenAI-compatible: prompt_tokens_details.cached_tokens + prompt_details = getattr(usage, "prompt_tokens_details", None) + if prompt_details is not None: + cached = getattr(prompt_details, "cached_tokens", None) + if cached and cached > 0: + invocation.usage_cache_read_input_tokens = cached + # Direct fields (some providers) + cache_creation = getattr( + usage, "cache_creation_input_tokens", None + ) or getattr(usage, "cache_creation_tokens", None) + if cache_creation and cache_creation > 0: + invocation.usage_cache_creation_input_tokens = cache_creation + cache_read = getattr(usage, "cache_read_input_tokens", None) + if ( + cache_read + and cache_read > 0 + and not invocation.usage_cache_read_input_tokens + ): + invocation.usage_cache_read_input_tokens = cache_read + return input_tokens, output_tokens, total_tokens @@ -683,6 +735,7 @@ def create_tool_invocation( tool_name=tool_name, provider=provider or _HERMES_AGENT_SYSTEM, ) + invocation.tool_type = "function" if invocation.provider: invocation.attributes["gen_ai.provider.name"] = invocation.provider if arguments is not None: diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/internal/_tracer.py b/instrumentation-loongsuite/loongsuite-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/internal/_tracer.py index 830159f5c..6edef11e5 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/internal/_tracer.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/internal/_tracer.py @@ -407,6 +407,7 @@ def _start_agent(self, run: Run) -> None: invocation = InvokeAgentInvocation( provider="langchain", agent_name=agent_name, + agent_id=agent_name or None, input_messages=input_messages, ) self._handler.start_invoke_agent(invocation, context=parent_ctx) @@ -571,6 +572,7 @@ def _on_tool_start(self, run: Run) -> None: tool_name=run.name or "unknown_tool", tool_call_arguments=input_str, tool_call_id=tool_call_id, + tool_type="function", ) self._handler.start_execute_tool(invocation, context=parent_ctx) rd = _RunData( diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-litellm/src/opentelemetry/instrumentation/litellm/_wrapper.py b/instrumentation-loongsuite/loongsuite-instrumentation-litellm/src/opentelemetry/instrumentation/litellm/_wrapper.py index f4a5bb9ff..ff3fb870e 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-litellm/src/opentelemetry/instrumentation/litellm/_wrapper.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-litellm/src/opentelemetry/instrumentation/litellm/_wrapper.py @@ -94,10 +94,8 @@ def __call__(self, *args, **kwargs): callback=None, invocation=invocation, ) - stream_wrapper.callback = ( - lambda span, - last_chunk, - error: self._handle_stream_end_with_handler( + stream_wrapper.callback = lambda span, last_chunk, error: ( + self._handle_stream_end_with_handler( invocation, last_chunk, error, stream_wrapper ) ) @@ -240,10 +238,8 @@ async def __call__(self, *args, **kwargs): callback=None, invocation=invocation, ) - stream_wrapper.callback = ( - lambda span, - last_chunk, - error: self._handle_stream_end_with_handler( + stream_wrapper.callback = lambda span, last_chunk, error: ( + self._handle_stream_end_with_handler( invocation, last_chunk, error, stream_wrapper ) ) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-minisweagent/src/opentelemetry/instrumentation/minisweagent/internal/agent_wrappers.py b/instrumentation-loongsuite/loongsuite-instrumentation-minisweagent/src/opentelemetry/instrumentation/minisweagent/internal/agent_wrappers.py index 7dfe3201e..c00401293 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-minisweagent/src/opentelemetry/instrumentation/minisweagent/internal/agent_wrappers.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-minisweagent/src/opentelemetry/instrumentation/minisweagent/internal/agent_wrappers.py @@ -113,7 +113,9 @@ def __call__( han.start_entry(entry_inv, context=context_api.get_current()) inv = InvokeAgentInvocation( - provider="minisweagent", agent_name=agent_name + provider="minisweagent", + agent_name=agent_name, + agent_id=agent_name, ) inv.request_model = _request_model_from_agent(instance) inv.attributes.setdefault("gen_ai.framework", "minisweagent") diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-qwen-agent/src/opentelemetry/instrumentation/qwen_agent/utils.py b/instrumentation-loongsuite/loongsuite-instrumentation-qwen-agent/src/opentelemetry/instrumentation/qwen_agent/utils.py index 269358540..91be544e8 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-qwen-agent/src/opentelemetry/instrumentation/qwen_agent/utils.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-qwen-agent/src/opentelemetry/instrumentation/qwen_agent/utils.py @@ -406,6 +406,7 @@ def _create_agent_invocation( invocation = InvokeAgentInvocation( provider=provider_name, agent_name=agent_name, + agent_id=agent_name, agent_description=agent_description, request_model=request_model, input_messages=input_messages, @@ -462,6 +463,7 @@ def _create_tool_invocation( return ExecuteToolInvocation( tool_name=tool_name, + tool_type="function", tool_call_arguments=parsed_args, tool_description=tool_description, ) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-vita/src/opentelemetry/instrumentation/vita/patch.py b/instrumentation-loongsuite/loongsuite-instrumentation-vita/src/opentelemetry/instrumentation/vita/patch.py index 965c86e99..03ae0df52 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-vita/src/opentelemetry/instrumentation/vita/patch.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-vita/src/opentelemetry/instrumentation/vita/patch.py @@ -300,6 +300,7 @@ def wrap_generate_next_message( invocation = InvokeAgentInvocation( provider="vitabench", agent_name=agent_name, + agent_id=agent_name, request_model=model, ) @@ -345,6 +346,17 @@ def wrap_generate_next_message( if usage and isinstance(usage, dict): invocation.input_tokens = usage.get("prompt_tokens") invocation.output_tokens = usage.get("completion_tokens") + # Cache tokens + prompt_details = usage.get("prompt_tokens_details") + if isinstance(prompt_details, dict): + cached = prompt_details.get("cached_tokens") + if cached and cached > 0: + invocation.usage_cache_read_input_tokens = cached + cache_creation = usage.get("cache_creation_input_tokens") + if cache_creation and cache_creation > 0: + invocation.usage_cache_creation_input_tokens = ( + cache_creation + ) handler.stop_invoke_agent(invocation) return result @@ -398,6 +410,13 @@ def wrap_generate( # response_model_name invocation.response_model_name = model + # response_id + resp_id = getattr(result, "id", None) or getattr( + result, "response_id", None + ) + if resp_id: + invocation.response_id = str(resp_id) + # finish_reasons if getattr(result, "tool_calls", None): invocation.finish_reasons = ["tool_calls"] @@ -409,6 +428,17 @@ def wrap_generate( if usage and isinstance(usage, dict): invocation.input_tokens = usage.get("prompt_tokens") invocation.output_tokens = usage.get("completion_tokens") + # Cache tokens (OpenAI-compatible format) + prompt_details = usage.get("prompt_tokens_details") + if isinstance(prompt_details, dict): + cached = prompt_details.get("cached_tokens") + if cached and cached > 0: + invocation.usage_cache_read_input_tokens = cached + cache_creation = usage.get("cache_creation_input_tokens") + if cache_creation and cache_creation > 0: + invocation.usage_cache_creation_input_tokens = ( + cache_creation + ) handler.stop_llm(invocation) return result @@ -433,6 +463,7 @@ def wrap_get_response( tool_name=tool_name, tool_call_id=tool_call_id, provider="vitabench", + tool_type="function", ) # tool_call_arguments diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-widesearch/src/opentelemetry/instrumentation/widesearch/utils.py b/instrumentation-loongsuite/loongsuite-instrumentation-widesearch/src/opentelemetry/instrumentation/widesearch/utils.py index 316f157c1..dfeb53e3e 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-widesearch/src/opentelemetry/instrumentation/widesearch/utils.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-widesearch/src/opentelemetry/instrumentation/widesearch/utils.py @@ -88,6 +88,7 @@ def _create_agent_invocation( invocation = InvokeAgentInvocation( provider="widesearch", agent_name=agent_name, + agent_id=agent_name, agent_description=instructions[:200] if instructions else "", request_model=request_model, input_messages=[ diff --git a/loongsuite-distro/src/loongsuite/distro/__init__.py b/loongsuite-distro/src/loongsuite/distro/__init__.py index 86d73b0e2..c9e2217d3 100644 --- a/loongsuite-distro/src/loongsuite/distro/__init__.py +++ b/loongsuite-distro/src/loongsuite/distro/__init__.py @@ -15,6 +15,8 @@ import os from typing import Any +from loongsuite.distro.resource import LoongSuiteResourceDetector + from opentelemetry.environment_variables import ( OTEL_LOGS_EXPORTER, OTEL_METRICS_EXPORTER, @@ -28,8 +30,22 @@ class LoongSuiteConfigurator(_OTelSDKConfigurator): """ LoongSuite configurator, inherits from OpenTelemetry SDK configurator. + + Augments the resource with LoongSuite specific attributes (``host.ip`` and + ``gen_ai.instrumentation.sdk.name``) before delegating to the OpenTelemetry + SDK configurator. """ + def _configure(self, **kwargs: Any) -> None: + resource_attributes = dict( + kwargs.pop("resource_attributes", None) or {} + ) + # Use setdefault so user-provided values take precedence over detector. + detected = LoongSuiteResourceDetector().detect().attributes + for k, v in detected.items(): + resource_attributes.setdefault(k, v) + super()._configure(resource_attributes=resource_attributes, **kwargs) + class LoongSuiteDistro(BaseDistro): """ diff --git a/loongsuite-distro/src/loongsuite/distro/resource.py b/loongsuite-distro/src/loongsuite/distro/resource.py new file mode 100644 index 000000000..d9cca93e9 --- /dev/null +++ b/loongsuite-distro/src/loongsuite/distro/resource.py @@ -0,0 +1,85 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import socket +from logging import getLogger + +from opentelemetry.sdk.resources import Resource, ResourceDetector + +logger = getLogger(__name__) + +# Resource attribute keys contributed by LoongSuite. +HOST_IP = "host.ip" +SERVICE_INSTANCE_ID = "service.instance.id" +GEN_AI_INSTRUMENTATION_SDK_NAME = "gen_ai.instrumentation.sdk.name" + +# Fixed value identifying the GenAI instrumentation SDK shipped with LoongSuite. +_GEN_AI_INSTRUMENTATION_SDK_NAME_VALUE = "loongsuite-genai-utils" + +_FALLBACK_HOST_IP = "127.0.0.1" + + +def _get_host_ip() -> str: + """Best-effort detection of the local host IP address. + + Opens a UDP socket towards a public address to discover which local + interface would be used for outbound traffic. No packet is actually sent. + Falls back to ``127.0.0.1`` when detection fails. + """ + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(("8.8.8.8", 80)) + return sock.getsockname()[0] + except OSError as exception: + logger.debug( + "Failed to detect host ip, falling back to %s. Exception: %s", + _FALLBACK_HOST_IP, + exception, + ) + return _FALLBACK_HOST_IP + finally: + if sock is not None: + sock.close() + + +def _get_host_ip_with_pid() -> str: + """Returns the host IP combined with the current process id. + + The format is ``-``, for example ``127.0.0.1-1``. + """ + return f"{_get_host_ip()}-{os.getpid()}" + + +class LoongSuiteResourceDetector(ResourceDetector): + """Detects LoongSuite specific resource attributes. + + Contributes the following attributes to the resource: + + * ``host.ip`` — the raw host IP address (e.g. ``127.0.0.1``). + * ``service.instance.id`` — ``-`` uniquely identifying the process + instance (e.g. ``127.0.0.1-1``). + * ``gen_ai.instrumentation.sdk.name`` set to ``loongsuite-genai-utils``. + """ + + def detect(self) -> Resource: + host_ip = _get_host_ip() + return Resource( + { + HOST_IP: host_ip, + SERVICE_INSTANCE_ID: f"{host_ip}-{os.getpid()}", + GEN_AI_INSTRUMENTATION_SDK_NAME: _GEN_AI_INSTRUMENTATION_SDK_NAME_VALUE, + } + ) diff --git a/loongsuite-distro/tests/__init__.py b/loongsuite-distro/tests/__init__.py new file mode 100644 index 000000000..b0a6f4284 --- /dev/null +++ b/loongsuite-distro/tests/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/loongsuite-distro/tests/test_configurator.py b/loongsuite-distro/tests/test_configurator.py new file mode 100644 index 000000000..e8c1c3ac2 --- /dev/null +++ b/loongsuite-distro/tests/test_configurator.py @@ -0,0 +1,70 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest import mock + +from loongsuite.distro import LoongSuiteConfigurator +from loongsuite.distro.resource import ( + GEN_AI_INSTRUMENTATION_SDK_NAME, + HOST_IP, + SERVICE_INSTANCE_ID, +) + + +class TestLoongSuiteConfigurator(unittest.TestCase): + @mock.patch("opentelemetry.sdk._configuration._initialize_components") + def test_configure_injects_loongsuite_attributes(self, mock_init): + LoongSuiteConfigurator().configure() + + mock_init.assert_called_once() + resource_attributes = mock_init.call_args.kwargs["resource_attributes"] + self.assertIn(HOST_IP, resource_attributes) + self.assertIn(SERVICE_INSTANCE_ID, resource_attributes) + self.assertEqual( + resource_attributes[GEN_AI_INSTRUMENTATION_SDK_NAME], + "loongsuite-genai-utils", + ) + + @mock.patch("opentelemetry.sdk._configuration._initialize_components") + def test_configure_preserves_existing_attributes(self, mock_init): + """User-provided values take precedence over detector values.""" + LoongSuiteConfigurator().configure( + resource_attributes={ + "service.name": "my-service", + HOST_IP: "custom-ip", + } + ) + + resource_attributes = mock_init.call_args.kwargs["resource_attributes"] + self.assertEqual(resource_attributes["service.name"], "my-service") + # User-provided host.ip should NOT be overwritten by detector + self.assertEqual(resource_attributes[HOST_IP], "custom-ip") + self.assertIn(SERVICE_INSTANCE_ID, resource_attributes) + self.assertIn(GEN_AI_INSTRUMENTATION_SDK_NAME, resource_attributes) + + @mock.patch("opentelemetry.sdk._configuration._initialize_components") + def test_configure_forwards_other_kwargs(self, mock_init): + LoongSuiteConfigurator().configure( + auto_instrumentation_version="1.2.3" + ) + + self.assertEqual( + mock_init.call_args.kwargs["auto_instrumentation_version"], + "1.2.3", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/loongsuite-distro/tests/test_resource.py b/loongsuite-distro/tests/test_resource.py new file mode 100644 index 000000000..289424fee --- /dev/null +++ b/loongsuite-distro/tests/test_resource.py @@ -0,0 +1,101 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest import mock + +from loongsuite.distro.resource import ( + GEN_AI_INSTRUMENTATION_SDK_NAME, + HOST_IP, + SERVICE_INSTANCE_ID, + LoongSuiteResourceDetector, + _get_host_ip, + _get_host_ip_with_pid, +) + +from opentelemetry.sdk.resources import Resource, ResourceDetector + + +class TestLoongSuiteResourceDetector(unittest.TestCase): + def test_is_resource_detector(self): + self.assertIsInstance(LoongSuiteResourceDetector(), ResourceDetector) + + def test_detect_returns_resource(self): + resource = LoongSuiteResourceDetector().detect() + self.assertIsInstance(resource, Resource) + + def test_detect_contains_expected_keys(self): + attributes = LoongSuiteResourceDetector().detect().attributes + self.assertIn(HOST_IP, attributes) + self.assertIn(SERVICE_INSTANCE_ID, attributes) + self.assertIn(GEN_AI_INSTRUMENTATION_SDK_NAME, attributes) + + def test_host_ip_is_raw_ip(self): + """host.ip should contain a raw IP address without PID suffix.""" + attributes = LoongSuiteResourceDetector().detect().attributes + # Raw IP should not contain a dash-PID suffix + self.assertNotIn("-", attributes[HOST_IP].rsplit(".", 1)[-1]) + + def test_gen_ai_instrumentation_sdk_name_value(self): + attributes = LoongSuiteResourceDetector().detect().attributes + self.assertEqual( + attributes[GEN_AI_INSTRUMENTATION_SDK_NAME], + "loongsuite-genai-utils", + ) + + @mock.patch("loongsuite.distro.resource.os.getpid", return_value=1) + @mock.patch( + "loongsuite.distro.resource._get_host_ip", return_value="127.0.0.1" + ) + def test_service_instance_id_format_is_ip_dash_pid( + self, _mock_ip, _mock_pid + ): + attributes = LoongSuiteResourceDetector().detect().attributes + self.assertEqual(attributes[SERVICE_INSTANCE_ID], "127.0.0.1-1") + self.assertEqual(attributes[HOST_IP], "127.0.0.1") + + @mock.patch("loongsuite.distro.resource.os.getpid", return_value=42) + @mock.patch( + "loongsuite.distro.resource._get_host_ip", return_value="10.0.0.5" + ) + def test_get_host_ip_with_pid(self, _mock_ip, _mock_pid): + self.assertEqual(_get_host_ip_with_pid(), "10.0.0.5-42") + + +class TestGetHostIp(unittest.TestCase): + def test_returns_detected_ip(self): + mock_sock = mock.MagicMock() + mock_sock.getsockname.return_value = ("192.168.1.100", 12345) + with mock.patch( + "loongsuite.distro.resource.socket.socket", + return_value=mock_sock, + ): + self.assertEqual(_get_host_ip(), "192.168.1.100") + mock_sock.connect.assert_called_once() + mock_sock.close.assert_called_once() + + def test_falls_back_to_loopback_on_error(self): + mock_sock = mock.MagicMock() + mock_sock.connect.side_effect = OSError("no network") + with mock.patch( + "loongsuite.distro.resource.socket.socket", + return_value=mock_sock, + ): + self.assertEqual(_get_host_ip(), "127.0.0.1") + # The socket must still be closed even when detection fails. + mock_sock.close.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tox-loongsuite.ini b/tox-loongsuite.ini index 0eda9c140..901f6a2b9 100644 --- a/tox-loongsuite.ini +++ b/tox-loongsuite.ini @@ -126,6 +126,9 @@ envlist = py3{10,11,12,13}-test-loongsuite-instrumentation-wildtool lint-loongsuite-instrumentation-wildtool + ; loongsuite-distro + py3{9,10,11,12,13}-test-loongsuite-distro + [testenv] test_deps = opentelemetry-api@{env:CORE_REPO}\#egg=opentelemetry-api&subdirectory=opentelemetry-api @@ -234,6 +237,9 @@ deps = wildtool: {[testenv]test_deps} wildtool: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-wildtool/test-requirements.txt + loongsuite-distro: {[testenv]test_deps} + loongsuite-distro: {toxinidir}/loongsuite-distro + ; FIXME: add coverage testing allowlist_externals = sh @@ -334,6 +340,8 @@ commands = test-loongsuite-instrumentation-wildtool: pytest {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-wildtool/tests {posargs} lint-loongsuite-instrumentation-wildtool: python -m ruff check {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-wildtool + test-loongsuite-distro: pytest {toxinidir}/loongsuite-distro/tests {posargs} + ; TODO: add coverage commands ; coverage: {toxinidir}/scripts/coverage.sh