diff --git a/.release-intents/20260816-python-claude-agent-cohere-crewai-tracing.json b/.release-intents/20260816-python-claude-agent-cohere-crewai-tracing.json new file mode 100644 index 00000000..b587bcac --- /dev/null +++ b/.release-intents/20260816-python-claude-agent-cohere-crewai-tracing.json @@ -0,0 +1,9 @@ +{ + "summary": "Repair canonical agent, rerank, and tool span content for the Claude Agent SDK, Cohere, and CrewAI instrumentations, including Claude agent/chat export splitting", + "packages": { + "respan-instrumentation-claude-agent-sdk": "patch", + "respan-instrumentation-cohere": "patch", + "respan-instrumentation-crewai": "patch", + "respan-tracing": "patch" + } +} diff --git a/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/src/respan_instrumentation_claude_agent_sdk/_constants.py b/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/src/respan_instrumentation_claude_agent_sdk/_constants.py index ae38ced0..b2872659 100644 --- a/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/src/respan_instrumentation_claude_agent_sdk/_constants.py +++ b/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/src/respan_instrumentation_claude_agent_sdk/_constants.py @@ -49,10 +49,6 @@ CLAUDE_AGENT_SDK_RESPONSE_FINISH_REASONS_ATTR, CLAUDE_AGENT_SDK_SYSTEM_INSTRUCTIONS_ATTR, CLAUDE_AGENT_SDK_TOOL_DEFINITIONS_ATTR, - CLAUDE_AGENT_SDK_USAGE_INPUT_TOKENS_ATTR, - CLAUDE_AGENT_SDK_USAGE_OUTPUT_TOKENS_ATTR, - SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS, - SpanAttributes.LLM_USAGE_CACHE_CREATION_INPUT_TOKENS, INPUT_VALUE_ATTR, OUTPUT_VALUE_ATTR, RESPAN_OVERRIDE_COMPLETION_TOKENS_ATTR, diff --git a/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/src/respan_instrumentation_claude_agent_sdk/_processor.py b/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/src/respan_instrumentation_claude_agent_sdk/_processor.py index 1e9029e6..1d8fd4f4 100644 --- a/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/src/respan_instrumentation_claude_agent_sdk/_processor.py +++ b/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/src/respan_instrumentation_claude_agent_sdk/_processor.py @@ -714,7 +714,9 @@ def enrich_claude_agent_sdk_span(span: ReadableSpan) -> None: _extract_usage(attrs) ) - _set_if_missing(attrs, RESPAN_LOG_TYPE, LOG_TYPE_AGENT) + # Upstream marks invoke_agent spans as chat because they also carry GenAI + # message attributes. The operation boundary is authoritative here. + attrs[RESPAN_LOG_TYPE] = LOG_TYPE_AGENT _set_if_missing(attrs, SpanAttributes.TRACELOOP_ENTITY_NAME, agent_name) _set_if_missing(attrs, SpanAttributes.TRACELOOP_ENTITY_PATH, agent_name) _set_if_missing(attrs, SpanAttributes.TRACELOOP_WORKFLOW_NAME, agent_name) @@ -762,6 +764,7 @@ def enrich_claude_agent_sdk_span(span: ReadableSpan) -> None: key: value for key, value in attrs.items() if key not in CLAUDE_AGENT_SDK_STRIP_ATTRS + and not key.startswith("gen_ai.tool.") } _set_if_unset_span_status(span, span._attributes) @@ -776,12 +779,18 @@ def __init__(self) -> None: ] = {} self._pending_tool_calls_lock = threading.Lock() - def _store_pending_tool_call(self, span: ReadableSpan) -> None: + def _store_pending_tool_call( + self, + span: ReadableSpan, + source_attrs: Mapping[str, Any] | None = None, + ) -> None: parent_span_key = _get_parent_span_key(span) if parent_span_key is None: return - attrs = getattr(span, "_attributes", None) + attrs = source_attrs + if attrs is None: + attrs = getattr(span, "_attributes", None) if not isinstance(attrs, Mapping): return @@ -821,6 +830,9 @@ def on_start(self, span: Any, parent_context: Any = None) -> None: def on_end(self, span: ReadableSpan) -> None: try: + original_attrs = getattr(span, "_attributes", None) + if isinstance(original_attrs, Mapping): + original_attrs = dict(original_attrs) enrich_claude_agent_sdk_span(span) attrs = getattr(span, "_attributes", None) @@ -829,7 +841,19 @@ def on_end(self, span: ReadableSpan) -> None: return if attrs.get(RESPAN_LOG_TYPE) == LOG_TYPE_TOOL: - self._store_pending_tool_call(span) + # Correlate with the upstream call ID before helper attributes + # are stripped, while using the normalized canonical name and + # arguments from the exported tool span. + pending_attrs = dict(attrs) + if isinstance(original_attrs, Mapping): + tool_call_id = original_attrs.get( + CLAUDE_AGENT_SDK_TOOL_CALL_ID_ATTR + ) + if tool_call_id: + pending_attrs[CLAUDE_AGENT_SDK_TOOL_CALL_ID_ATTR] = ( + tool_call_id + ) + self._store_pending_tool_call(span, pending_attrs) # Only agent spans merge queued tool calls into their final attrs. # Drop any child calls queued against non-agent parents on span end. self._consume_pending_tool_calls(span) diff --git a/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/tests/test_instrumentation.py b/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/tests/test_instrumentation.py index b0940aba..7e20453c 100644 --- a/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/tests/test_instrumentation.py +++ b/python-sdks/instrumentations/respan-instrumentation-claude-agent-sdk/tests/test_instrumentation.py @@ -786,11 +786,14 @@ def test_enrich_claude_agent_sdk_span_maps_agent_fields(): assert span._attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] == 19 assert span._attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] == 7 assert span._attributes[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] == 26 + assert span._attributes["gen_ai.usage.input_tokens"] == 19 + assert span._attributes["gen_ai.usage.output_tokens"] == 7 assert span._attributes[RESPAN_SESSION_ID] == "session-123" assert span._status.status_code == StatusCode.OK assert json.loads(span._attributes[SpanAttributes.LLM_REQUEST_FUNCTIONS]) == [ {"type": "function", "function": {"name": "get_weather"}} ] + assert not any(key.startswith("gen_ai.tool.") for key in span._attributes) assert json.loads(span._attributes[_COMPLETION_TOOL_CALLS_ATTR]) == [ { "id": "toolu_123", @@ -810,6 +813,36 @@ def test_enrich_claude_agent_sdk_span_maps_agent_fields(): _assert_no_banned_aliases(span._attributes) +def test_enrich_agent_overrides_chat_and_keeps_session_cost_and_cache_usage(): + span = _make_span( + name="invoke_agent research_agent", + attributes={ + "gen_ai.operation.name": "invoke_agent", + "gen_ai.agent.name": "research_agent", + "gen_ai.conversation.id": "session-resume-1", + "gen_ai.usage.input_tokens": 20, + "gen_ai.usage.output_tokens": 4, + SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS: 11, + SpanAttributes.LLM_USAGE_CACHE_CREATION_INPUT_TOKENS: 3, + "respan.metadata.response_cost": "0.003", + RESPAN_LOG_TYPE: LOG_TYPE_CHAT, + }, + ) + + _processor.enrich_claude_agent_sdk_span(span) + + assert span._attributes[RESPAN_LOG_TYPE] == LOG_TYPE_AGENT + assert span._attributes[RESPAN_SESSION_ID] == "session-resume-1" + assert span._attributes["respan.metadata.response_cost"] == "0.003" + assert span._attributes[SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS] == 11 + assert ( + span._attributes[SpanAttributes.LLM_USAGE_CACHE_CREATION_INPUT_TOKENS] == 3 + ) + assert span._attributes["gen_ai.usage.input_tokens"] == 20 + assert span._attributes["gen_ai.usage.output_tokens"] == 4 + _assert_no_banned_aliases(span._attributes) + + def test_enrich_claude_agent_sdk_span_maps_tool_fields(): span = _make_span( name="execute_tool mcp__demo__calculator", @@ -846,6 +879,7 @@ def test_enrich_claude_agent_sdk_span_maps_tool_fields(): assert CLAUDE_AGENT_SDK_TOOL_NAME_ATTR not in span._attributes assert CLAUDE_AGENT_SDK_TOOL_CALL_ARGUMENTS_ATTR not in span._attributes assert CLAUDE_AGENT_SDK_TOOL_CALL_RESULT_ATTR not in span._attributes + assert not any(key.startswith("gen_ai.tool.") for key in span._attributes) def test_enrich_claude_agent_sdk_span_overrides_upstream_tool_chat_defaults(): @@ -1020,6 +1054,7 @@ def test_span_processor_on_end_merges_pending_tool_calls_into_parent_agent_span( processor.on_end(tool_span) processor.on_end(agent_span) + assert not any(key.startswith("gen_ai.tool.") for key in tool_span._attributes) assert json.loads(agent_span._attributes[_COMPLETION_TOOL_CALLS_ATTR]) == [ { "id": "toolu_123", @@ -1104,15 +1139,15 @@ def test_span_processor_on_end_discards_pending_tool_calls_for_tool_parent_span( assert (57, 1) in processor._pending_tool_calls_by_parent -def test_span_processor_on_end_leaves_final_chat_child_to_shared_exporter(): +def test_span_processor_on_end_splits_real_scope_agent_and_chat_contracts(): processor = _processor.ClaudeAgentSDKSpanProcessor() agent_span = _make_span( - name="ClaudeAgentSDK.query", + name="invoke_agent weather_agent", trace_id=88, span_id=7, start_time=100, - scope_name="openinference.instrumentation.claude_agent_sdk", + scope_name="opentelemetry.instrumentation.claude_agent_sdk", attributes={ "gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": "weather_agent", @@ -1138,6 +1173,10 @@ def test_span_processor_on_end_leaves_final_chat_child_to_shared_exporter(): ] ), "gen_ai.response.model": "claude-sonnet-4-5", + "gen_ai.usage.input_tokens": 19, + "gen_ai.usage.output_tokens": 7, + SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS: 3, + SpanAttributes.LLM_REQUEST_TYPE: "chat", }, ) @@ -1176,12 +1215,30 @@ def test_span_processor_on_end_leaves_final_chat_child_to_shared_exporter(): prepared_spans = _prepare_spans_for_export(spans=[agent_span]) assert [span.name for span in prepared_spans] == [ - "ClaudeAgentSDK.query", + "invoke_agent weather_agent", "assistant_message", ] - assert prepared_spans[1].attributes[_COMPLETION_TOOL_CALLS_ATTR] == json.loads( + prepared_agent_attrs = prepared_spans[0].attributes + prepared_chat_attrs = prepared_spans[1].attributes + assert prepared_agent_attrs[RESPAN_LOG_TYPE] == LOG_TYPE_AGENT + assert SpanAttributes.LLM_REQUEST_TYPE not in prepared_agent_attrs + assert SpanAttributes.LLM_REQUEST_MODEL not in prepared_agent_attrs + assert SpanAttributes.LLM_SYSTEM not in prepared_agent_attrs + assert not any(key.startswith("gen_ai.usage.") for key in prepared_agent_attrs) + assert not any(key.startswith("llm.usage.") for key in prepared_agent_attrs) + assert _COMPLETION_TOOL_CALLS_ATTR not in prepared_agent_attrs + assert prepared_chat_attrs[RESPAN_LOG_TYPE] == LOG_TYPE_CHAT + assert prepared_chat_attrs[SpanAttributes.LLM_REQUEST_TYPE] == "chat" + assert prepared_chat_attrs[SpanAttributes.LLM_REQUEST_MODEL] == "claude-sonnet-4-5" + assert prepared_chat_attrs["gen_ai.usage.input_tokens"] == 19 + assert prepared_chat_attrs["gen_ai.usage.output_tokens"] == 7 + assert prepared_chat_attrs[SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS] == 3 + assert json.loads(prepared_chat_attrs[_COMPLETION_TOOL_CALLS_ATTR]) == json.loads( agent_span._attributes[_COMPLETION_TOOL_CALLS_ATTR] ) + assert prepared_chat_attrs[f"{SpanAttributes.LLM_COMPLETIONS}.0.content"] == ( + "Tokyo is sunny." + ) def test_span_processor_shutdown_clears_pending_calls_and_force_flush_returns_true(): diff --git a/python-sdks/instrumentations/respan-instrumentation-cohere/src/respan_instrumentation_cohere/_instrumentation.py b/python-sdks/instrumentations/respan-instrumentation-cohere/src/respan_instrumentation_cohere/_instrumentation.py index 250d83f0..bf1611b9 100644 --- a/python-sdks/instrumentations/respan-instrumentation-cohere/src/respan_instrumentation_cohere/_instrumentation.py +++ b/python-sdks/instrumentations/respan-instrumentation-cohere/src/respan_instrumentation_cohere/_instrumentation.py @@ -1,10 +1,14 @@ """Cohere instrumentation plugin for Respan.""" import importlib +import json import logging +from collections.abc import Mapping, Sequence +import threading from typing import Any from opentelemetry import trace +from opentelemetry.semconv_ai import LLMRequestTypeValues, SpanAttributes from respan_instrumentation_cohere._processor import ( CohereSpanProcessor, insert_span_processor_before_export, @@ -17,10 +21,158 @@ COHERE_INSTRUMENTATION_NAME = "cohere" OTEL_COHERE_MODULE = "opentelemetry.instrumentation.cohere" +_CONTENT_PATCH_LOCK = threading.RLock() +_CONTENT_PATCH_USERS = 0 +_CONTENT_PATCH_ORIGINALS: dict[str, Any] = {} -def _load_otel_cohere_class() -> type: - cohere_module = importlib.import_module(OTEL_COHERE_MODULE) - return cohere_module.CohereInstrumentor + +def _load_otel_cohere_module() -> Any: + return importlib.import_module(OTEL_COHERE_MODULE) + + +def _structured_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, Mapping): + return {str(key): _structured_value(item) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_structured_value(item) for item in value] + for method_name in ("model_dump", "dict"): + method = getattr(value, method_name, None) + if not callable(method): + continue + try: + dumped = method() + except Exception: + continue + if isinstance(dumped, Mapping): + return _structured_value(dumped) + attributes = getattr(value, "__dict__", None) + if isinstance(attributes, Mapping): + return _structured_value( + { + key: item + for key, item in attributes.items() + if not str(key).startswith("_") + } + ) + return str(value) + + +def _json_attribute(value: Any) -> str: + return json.dumps( + _structured_value(value), + ensure_ascii=False, + separators=(",", ":"), + ) + + +def _rerank_input(kwargs: Mapping[str, Any]) -> dict[str, Any]: + return { + key: _structured_value(kwargs[key]) + for key in ("model", "query", "documents", "top_n") + if kwargs.get(key) is not None + } + + +def _rerank_output(response: Any) -> dict[str, Any]: + result: dict[str, Any] = {} + response_id = getattr(response, "id", None) + if response_id is not None: + result["id"] = response_id + + ranked_results: list[dict[str, Any]] = [] + for item in getattr(response, "results", None) or []: + normalized = _structured_value(item) + if isinstance(normalized, Mapping): + ranked_results.append( + { + key: normalized[key] + for key in ("index", "relevance_score", "document") + if normalized.get(key) is not None + } + ) + else: + ranked_results.append({"value": normalized}) + result["results"] = ranked_results + return result + + +def _install_content_patch(cohere_module: Any) -> bool: + global _CONTENT_PATCH_USERS + with _CONTENT_PATCH_LOCK: + if _CONTENT_PATCH_USERS: + _CONTENT_PATCH_USERS += 1 + return True + + original_input = getattr(cohere_module, "set_input_content_attributes", None) + original_response = getattr( + cohere_module, + "set_response_content_attributes", + None, + ) + if not callable(original_input) or not callable(original_response): + return False + + def set_input_content_attributes(span, llm_request_type, kwargs): + original_input(span, llm_request_type, kwargs) + if ( + llm_request_type != LLMRequestTypeValues.RERANK + or not span.is_recording() + ): + return + payload = _rerank_input(kwargs) + span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, "rerank") + span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_PATH, "rerank") + span.set_attribute( + SpanAttributes.TRACELOOP_ENTITY_INPUT, + _json_attribute(payload), + ) + + def set_response_content_attributes(span, llm_request_type, response): + original_response(span, llm_request_type, response) + if ( + llm_request_type != LLMRequestTypeValues.RERANK + or not span.is_recording() + ): + return + span.set_attribute( + SpanAttributes.TRACELOOP_ENTITY_OUTPUT, + _json_attribute(_rerank_output(response)), + ) + + _CONTENT_PATCH_ORIGINALS.update( + { + "module": cohere_module, + "set_input_content_attributes": original_input, + "set_response_content_attributes": original_response, + } + ) + cohere_module.set_input_content_attributes = set_input_content_attributes + cohere_module.set_response_content_attributes = set_response_content_attributes + _CONTENT_PATCH_USERS = 1 + return True + + +def _remove_content_patch() -> None: + global _CONTENT_PATCH_USERS + with _CONTENT_PATCH_LOCK: + if _CONTENT_PATCH_USERS == 0: + return + _CONTENT_PATCH_USERS -= 1 + if _CONTENT_PATCH_USERS: + return + + cohere_module = _CONTENT_PATCH_ORIGINALS.get("module") + if cohere_module is not None: + for name in ( + "set_input_content_attributes", + "set_response_content_attributes", + ): + original = _CONTENT_PATCH_ORIGINALS.get(name) + if original is not None: + setattr(cohere_module, name, original) + _CONTENT_PATCH_ORIGINALS.clear() class CohereInstrumentor: @@ -44,6 +196,7 @@ def __init__( self._processor = None self._is_instrumented = False self._owns_instrumentation = False + self._owns_content_patch = False @staticmethod def _is_respan_tracing_enabled() -> bool: @@ -64,7 +217,8 @@ def activate(self) -> None: return try: - cohere_instrumentor_class = _load_otel_cohere_class() + cohere_module = _load_otel_cohere_module() + cohere_instrumentor_class = cohere_module.CohereInstrumentor except ImportError as exc: logger.warning( "Failed to activate Cohere instrumentation - missing dependency: %s", @@ -74,6 +228,7 @@ def activate(self) -> None: tracer_provider = trace.get_tracer_provider() try: + self._owns_content_patch = _install_content_patch(cohere_module) self._processor = CohereSpanProcessor() insert_span_processor_before_export(tracer_provider, self._processor) @@ -100,6 +255,9 @@ def activate(self) -> None: remove_span_processor(tracer_provider, self._processor) self._instrumentor = None self._processor = None + if self._owns_content_patch: + _remove_content_patch() + self._owns_content_patch = False logger.warning( "Cohere instrumentation skipped because the upstream " "instrumentor did not activate" @@ -120,6 +278,9 @@ def activate(self) -> None: self._processor = None self._is_instrumented = False self._owns_instrumentation = False + if self._owns_content_patch: + _remove_content_patch() + self._owns_content_patch = False logger.exception("Failed to activate Cohere instrumentation") def deactivate(self) -> None: @@ -136,8 +297,11 @@ def deactivate(self) -> None: logger.exception("Failed to deactivate Cohere instrumentation") if self._processor is not None: remove_span_processor(tracer_provider, self._processor) + if self._owns_content_patch: + _remove_content_patch() self._instrumentor = None self._processor = None self._is_instrumented = False self._owns_instrumentation = False + self._owns_content_patch = False logger.info("Cohere instrumentation deactivated") diff --git a/python-sdks/instrumentations/respan-instrumentation-cohere/src/respan_instrumentation_cohere/_processor.py b/python-sdks/instrumentations/respan-instrumentation-cohere/src/respan_instrumentation_cohere/_processor.py index cc8285b5..85bad55d 100644 --- a/python-sdks/instrumentations/respan-instrumentation-cohere/src/respan_instrumentation_cohere/_processor.py +++ b/python-sdks/instrumentations/respan-instrumentation-cohere/src/respan_instrumentation_cohere/_processor.py @@ -281,11 +281,13 @@ def _stringify_structured_canonical_values(attrs: dict[str, Any]) -> None: attrs[_FUNCTIONS_ATTR] = _safe_json_str(attrs[_FUNCTIONS_ATTR]) for key, value in list(attrs.items()): - if not (key.startswith(_PROMPT_PREFIX) or key.startswith(_COMPLETION_PREFIX)): + if not key.startswith((_PROMPT_PREFIX, _COMPLETION_PREFIX)): continue - if key.endswith(f".{_TOOL_CALL_PATH}"): - attrs[key] = _safe_json_str(value) - elif key.endswith(".content") and isinstance(value, (dict, list)): + if ( + key.endswith(f".{_TOOL_CALL_PATH}") + or key.endswith(".content") + and isinstance(value, (dict, list)) + ): attrs[key] = _safe_json_str(value) @@ -294,13 +296,18 @@ def _normalize_cohere_attrs( attrs: dict[str, Any], ) -> None: request_type = _request_type_from_span(span, attrs) - if request_type: - attrs[SpanAttributes.LLM_REQUEST_TYPE] = request_type - log_type = _log_type_for_request_type(request_type) if log_type is not None: attrs.setdefault(RESPAN_LOG_TYPE, log_type) + if request_type in { + LLMRequestTypeValues.CHAT.value, + LLMRequestTypeValues.COMPLETION.value, + }: + attrs[SpanAttributes.LLM_REQUEST_TYPE] = LLMRequestTypeValues.CHAT.value + elif request_type: + attrs[SpanAttributes.LLM_REQUEST_TYPE] = request_type + attrs[SpanAttributes.LLM_SYSTEM] = "cohere" _set_token_aliases(attrs) _normalize_indexed_functions(attrs) diff --git a/python-sdks/instrumentations/respan-instrumentation-cohere/tests/test_instrumentation.py b/python-sdks/instrumentations/respan-instrumentation-cohere/tests/test_instrumentation.py index c7a876ec..1be83b34 100644 --- a/python-sdks/instrumentations/respan-instrumentation-cohere/tests/test_instrumentation.py +++ b/python-sdks/instrumentations/respan-instrumentation-cohere/tests/test_instrumentation.py @@ -2,15 +2,14 @@ import logging import sys from types import ModuleType, SimpleNamespace +from typing import ClassVar import pytest from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) from opentelemetry.semconv_ai import LLMRequestTypeValues, SpanAttributes - -from respan_instrumentation_cohere import CohereInstrumentor -from respan_instrumentation_cohere import _instrumentation +from respan_instrumentation_cohere import CohereInstrumentor, _instrumentation from respan_instrumentation_cohere._instrumentation import OTEL_COHERE_MODULE from respan_instrumentation_cohere._processor import ( COHERE_SCOPE_NAME, @@ -49,7 +48,7 @@ def __init__(self, attrs, name="cohere.chat"): def _install_fake_modules(monkeypatch): class FakeOTELCohereInstrumentor: - created = [] + created: ClassVar[list["FakeOTELCohereInstrumentor"]] = [] def __init__(self, **kwargs): self.constructor_kwargs = kwargs @@ -367,6 +366,104 @@ def test_processor_maps_embedding_and_rerank_log_types(): assert rerank_span._attributes[RESPAN_LOG_TYPE] == "task" +def test_processor_maps_text_completion_to_text_log_with_chat_request_type(): + span = FakeSpan( + { + SpanAttributes.LLM_SYSTEM: "Cohere", + SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value, + }, + name="cohere.completion", + ) + + CohereSpanProcessor().on_end(span) + + assert span._attributes[RESPAN_LOG_TYPE] == "text" + assert ( + span._attributes[SpanAttributes.LLM_REQUEST_TYPE] + == LLMRequestTypeValues.CHAT.value + ) + + +def test_rerank_content_patch_preserves_structured_input_and_output(): + calls = [] + + def original_input(span, request_type, kwargs): + calls.append(("input", request_type, kwargs)) + + def original_response(span, request_type, response): + calls.append(("response", request_type, response)) + + module = SimpleNamespace( + set_input_content_attributes=original_input, + set_response_content_attributes=original_response, + ) + + class RecordingSpan: + def __init__(self): + self.attributes = {} + + def is_recording(self): + return True + + def set_attribute(self, key, value): + self.attributes[key] = value + + span = RecordingSpan() + response = SimpleNamespace( + id="rerank-1", + results=[ + SimpleNamespace( + index=1, + relevance_score=0.97, + document=SimpleNamespace(text="second document"), + ) + ], + ) + + assert _instrumentation._install_content_patch(module) is True + try: + module.set_input_content_attributes( + span, + LLMRequestTypeValues.RERANK, + { + "model": "rerank-v3.5", + "query": "best document", + "documents": ["first document", "second document"], + "top_n": 1, + }, + ) + module.set_response_content_attributes( + span, + LLMRequestTypeValues.RERANK, + response, + ) + + assert json.loads(span.attributes[SpanAttributes.TRACELOOP_ENTITY_INPUT]) == { + "model": "rerank-v3.5", + "query": "best document", + "documents": ["first document", "second document"], + "top_n": 1, + } + assert json.loads(span.attributes[SpanAttributes.TRACELOOP_ENTITY_OUTPUT]) == { + "id": "rerank-1", + "results": [ + { + "index": 1, + "relevance_score": 0.97, + "document": {"text": "second document"}, + } + ], + } + assert span.attributes[SpanAttributes.TRACELOOP_ENTITY_NAME] == "rerank" + assert span.attributes[SpanAttributes.TRACELOOP_ENTITY_PATH] == "rerank" + assert [call[0] for call in calls] == ["input", "response"] + finally: + _instrumentation._remove_content_patch() + + assert module.set_input_content_attributes is original_input + assert module.set_response_content_attributes is original_response + + def test_processor_ignores_non_cohere_spans(): span = FakeSpan({SpanAttributes.LLM_SYSTEM: "openai"}, name="openai.chat") span.instrumentation_scope = SimpleNamespace( diff --git a/python-sdks/instrumentations/respan-instrumentation-crewai/src/respan_instrumentation_crewai/_event_listener.py b/python-sdks/instrumentations/respan-instrumentation-crewai/src/respan_instrumentation_crewai/_event_listener.py index a37797c0..0eeff834 100644 --- a/python-sdks/instrumentations/respan-instrumentation-crewai/src/respan_instrumentation_crewai/_event_listener.py +++ b/python-sdks/instrumentations/respan-instrumentation-crewai/src/respan_instrumentation_crewai/_event_listener.py @@ -76,6 +76,7 @@ json_attribute, normalize_provider, normalize_token_usage, + normalize_tool_definitions, set_llm_message_attributes, ) @@ -316,10 +317,12 @@ def _llm_start_spec(source: Any, event: LLMCallStartedEvent) -> SpanStartSpec: tools = getattr(event, "tools", None) available_functions = getattr(event, "available_functions", None) if tools: - attributes[SpanAttributes.LLM_REQUEST_FUNCTIONS] = json_attribute(tools) + attributes[SpanAttributes.LLM_REQUEST_FUNCTIONS] = json_attribute( + normalize_tool_definitions(tools) + ) elif available_functions: attributes[SpanAttributes.LLM_REQUEST_FUNCTIONS] = json_attribute( - available_functions + normalize_tool_definitions(available_functions) ) request_values = ( diff --git a/python-sdks/instrumentations/respan-instrumentation-crewai/src/respan_instrumentation_crewai/_serialization.py b/python-sdks/instrumentations/respan-instrumentation-crewai/src/respan_instrumentation_crewai/_serialization.py index f90df9eb..3ce1679f 100644 --- a/python-sdks/instrumentations/respan-instrumentation-crewai/src/respan_instrumentation_crewai/_serialization.py +++ b/python-sdks/instrumentations/respan-instrumentation-crewai/src/respan_instrumentation_crewai/_serialization.py @@ -2,23 +2,86 @@ from __future__ import annotations +import ast from collections.abc import Mapping, Sequence import json from typing import Any from opentelemetry.semconv_ai import SpanAttributes -from respan_sdk.utils.serialization import serialize_value - from respan_instrumentation_crewai._constants import ASSISTANT_ROLE, USER_ROLE +def _structured_value(value: Any, *, parse_tool_strings: bool = False) -> Any: + """Convert provider/Pydantic containers to JSON-native values.""" + if value is None or isinstance(value, (int, float, bool)): + return value + if isinstance(value, str): + if not parse_tool_strings: + return value + parsed = _parse_tool_string(value) + if parsed is value: + return value + return _structured_value(parsed, parse_tool_strings=True) + if isinstance(value, Mapping): + return { + str(key): _structured_value(item, parse_tool_strings=parse_tool_strings) + for key, item in value.items() + } + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): + return [ + _structured_value(item, parse_tool_strings=parse_tool_strings) + for item in value + ] + + for method_name in ("model_dump", "dict"): + method = getattr(value, method_name, None) + if not callable(method): + continue + try: + dumped = method() + except Exception: + continue + if isinstance(dumped, Mapping): + return _structured_value( + dumped, + parse_tool_strings=parse_tool_strings, + ) + + attributes = getattr(value, "__dict__", None) + if isinstance(attributes, Mapping): + return _structured_value( + { + key: item + for key, item in attributes.items() + if not str(key).startswith("_") + }, + parse_tool_strings=parse_tool_strings, + ) + return str(value) + + +def _parse_tool_string(value: str) -> Any: + stripped = value.strip() + if len(stripped) < 2: + return value + is_container = (stripped[0], stripped[-1]) in {("{", "}"), ("[", "]")} + is_quoted = stripped[0] == stripped[-1] and stripped[0] in {"'", '"'} + if not is_container and not is_quoted: + return value + for parser in (json.loads, ast.literal_eval): + try: + return parser(stripped) + except (TypeError, ValueError, SyntaxError, json.JSONDecodeError): + continue + return value + + def json_attribute(value: Any) -> str: """Return an OTel-safe JSON string for a structured value.""" try: - serialized = serialize_value(value=value) return json.dumps( - serialized, + _structured_value(value), default=str, ensure_ascii=False, separators=(",", ":"), @@ -34,6 +97,60 @@ def attribute_text(value: Any) -> str: return json_attribute(value) +def normalize_tool_definitions(value: Any) -> Any: + """Return tool schemas without Python repr or nested quote artifacts.""" + return _structured_value(value, parse_tool_strings=True) + + +def _tool_arguments(value: Any) -> str: + normalized = _structured_value(value, parse_tool_strings=True) + if isinstance(normalized, str): + return normalized + return json.dumps( + normalized, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def _normalize_tool_call(value: Any) -> Any: + normalized = _structured_value(value, parse_tool_strings=True) + if not isinstance(normalized, Mapping): + return normalized + + call = dict(normalized) + function = call.get("function") + if isinstance(function, Mapping): + normalized_function = dict(function) + if normalized_function.get("arguments") is not None: + normalized_function["arguments"] = _tool_arguments( + normalized_function["arguments"] + ) + call["function"] = normalized_function + elif call.get("name") is not None: + normalized_function = {"name": call.pop("name")} + arguments = call.pop("arguments", call.pop("input", None)) + if arguments is not None: + normalized_function["arguments"] = _tool_arguments(arguments) + call["function"] = normalized_function + call.setdefault("type", "function") + + if call.get("id") is None and call.get("tool_use_id") is not None: + call["id"] = call.pop("tool_use_id") + return call + + +def normalize_tool_calls(value: Any) -> list[Any]: + """Return OpenAI-shaped tool calls with JSON-string arguments.""" + normalized = _structured_value(value, parse_tool_strings=True) + if isinstance(normalized, Sequence) and not isinstance( + normalized, + (str, bytes, bytearray), + ): + return [_normalize_tool_call(item) for item in normalized] + return [_normalize_tool_call(normalized)] + + def normalize_messages( value: Any, *, default_role: str = USER_ROLE ) -> list[dict[str, Any]]: @@ -99,7 +216,10 @@ def _tool_call_completion_message( if isinstance(response, Sequence) and not isinstance( response, (str, bytes, bytearray) ): - return {"role": ASSISTANT_ROLE, "tool_calls": list(response)} + return { + "role": ASSISTANT_ROLE, + "tool_calls": normalize_tool_calls(response), + } if response_mapping is None: return None @@ -107,11 +227,17 @@ def _tool_call_completion_message( if isinstance(nested_tool_calls, Sequence) and not isinstance( nested_tool_calls, (str, bytes, bytearray) ): - return {"role": ASSISTANT_ROLE, "tool_calls": list(nested_tool_calls)} + return { + "role": ASSISTANT_ROLE, + "tool_calls": normalize_tool_calls(nested_tool_calls), + } tool_call_keys = {"id", "function", "name", "arguments", "input", "tool_use_id"} if tool_call_keys.intersection(response_mapping): - return {"role": ASSISTANT_ROLE, "tool_calls": [dict(response_mapping)]} + return { + "role": ASSISTANT_ROLE, + "tool_calls": normalize_tool_calls(response_mapping), + } return None @@ -174,7 +300,13 @@ def set_message_attributes( if content is not None: attributes[f"{message_prefix}.content"] = attribute_text(content) if tool_calls: - attributes[f"{message_prefix}.tool_calls"] = json_attribute(tool_calls) + attributes[f"{message_prefix}.tool_calls"] = json_attribute( + normalize_tool_calls(tool_calls) + ) + for identity_field in ("tool_call_id", "name"): + identity = message.get(identity_field) + if identity is not None: + attributes[f"{message_prefix}.{identity_field}"] = str(identity) def first_int(mapping: Mapping[str, Any], *keys: str) -> int | None: diff --git a/python-sdks/instrumentations/respan-instrumentation-crewai/tests/test_llm_events.py b/python-sdks/instrumentations/respan-instrumentation-crewai/tests/test_llm_events.py index e4ff696e..ec81c9b1 100644 --- a/python-sdks/instrumentations/respan-instrumentation-crewai/tests/test_llm_events.py +++ b/python-sdks/instrumentations/respan-instrumentation-crewai/tests/test_llm_events.py @@ -50,7 +50,12 @@ SpanStartSpec, ) from respan_instrumentation_crewai._event_listener import CrewAIEventListener -from respan_instrumentation_crewai._serialization import completion_message +from respan_instrumentation_crewai._serialization import ( + completion_message, + normalize_tool_calls, + normalize_tool_definitions, + set_message_attributes, +) from respan_sdk.constants import ERROR_MESSAGE_ATTR from respan_sdk.constants.llm_logging import ( LOG_TYPE_AGENT, @@ -304,7 +309,6 @@ def test_native_listener_exports_canonical_full_lifecycle(monkeypatch): assert chat_span.parent.span_id == agent_span.context.span_id assert tool_span.parent.span_id == agent_span.context.span_id assert len({span.context.trace_id for span in spans}) == 1 - for span in spans: attributes = span.attributes or {} assert ( @@ -493,6 +497,80 @@ def test_tool_call_completion_uses_canonical_indexed_tool_calls(monkeypatch): _finish_tracing(tracer_provider, instrumentor, original_token_hook) +def test_tool_payloads_remove_repr_quoting_and_preserve_tool_result_identity(): + tools = normalize_tool_definitions( + [ + { + "type": "'function'", + "function": { + "name": '"lookup"', + "description": "Look up a topic", + "parameters": ( + "{'type':'object','properties':" + "{'topic':{'type':'string'}},'required':['topic']}" + ), + }, + } + ] + ) + tool_calls = normalize_tool_calls( + [ + { + "id": "'call-1'", + "type": "'function'", + "function": { + "name": "'lookup'", + "arguments": "{'topic':'reason'}", + }, + } + ] + ) + + assert tools == [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Look up a topic", + "parameters": { + "type": "object", + "properties": {"topic": {"type": "string"}}, + "required": ["topic"], + }, + }, + } + ] + assert tool_calls == [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "lookup", + "arguments": '{"topic":"reason"}', + }, + } + ] + + attributes = {} + set_message_attributes( + attributes, + prefix=SpanAttributes.LLM_PROMPTS, + messages=[ + { + "role": "tool", + "name": "lookup", + "tool_call_id": "call-1", + "content": {"answer": "Because."}, + } + ], + ) + assert attributes[f"{SpanAttributes.LLM_PROMPTS}.0.name"] == "lookup" + assert attributes[f"{SpanAttributes.LLM_PROMPTS}.0.tool_call_id"] == "call-1" + assert json.loads(attributes[f"{SpanAttributes.LLM_PROMPTS}.0.content"]) == { + "answer": "Because." + } + + def test_serialized_pydantic_completion_preserves_structured_content(monkeypatch): class StructuredResponse(BaseModel): answer: str diff --git a/python-sdks/respan-tracing/src/respan_tracing/exporters/respan.py b/python-sdks/respan-tracing/src/respan_tracing/exporters/respan.py index d4cce642..e4853671 100644 --- a/python-sdks/respan-tracing/src/respan_tracing/exporters/respan.py +++ b/python-sdks/respan-tracing/src/respan_tracing/exporters/respan.py @@ -167,7 +167,10 @@ def get_span_context(self) -> Any: return self._span_context -_CLAUDE_AGENT_SCOPE_NAME = "openinference.instrumentation.claude_agent_sdk" +_CLAUDE_AGENT_SCOPE_NAMES = frozenset({ + "openinference.instrumentation.claude_agent_sdk", + "opentelemetry.instrumentation.claude_agent_sdk", +}) _CLAUDE_AGENT_RESPONSE_SPAN_NAMES = frozenset({ "ClaudeAgentSDK.query", "ClaudeAgentSDK.ClaudeSDKClient.receive_response", @@ -193,16 +196,20 @@ def _is_claude_agent_response_span(span: ReadableSpan) -> bool: """Return whether this span is a Claude Agent SDK response-turn parent.""" scope = getattr(span, "instrumentation_scope", None) scope_name = getattr(scope, "name", None) + attrs = span.attributes or {} return ( - scope_name == _CLAUDE_AGENT_SCOPE_NAME - and span.name in _CLAUDE_AGENT_RESPONSE_SPAN_NAMES + scope_name in _CLAUDE_AGENT_SCOPE_NAMES + and ( + attrs.get(RESPAN_LOG_TYPE) == LOG_TYPE_AGENT + or span.name in _CLAUDE_AGENT_RESPONSE_SPAN_NAMES + ) ) def _build_claude_agent_final_chat_span( span: ReadableSpan, ) -> Optional[ReadableSpan]: - """Synthesize the missing final child chat span for Claude Agent tool turns.""" + """Split Claude Agent SDK LLM data into a canonical child chat span.""" if not _is_claude_agent_response_span(span): return None @@ -210,12 +217,28 @@ def _build_claude_agent_final_chat_span( tool_calls = _parse_structured_json_attr(attrs.get(_COMPLETION_TOOL_CALLS_ATTR)) if not isinstance(tool_calls, list) or not tool_calls: tool_calls = _parse_structured_json_attr(attrs.get(RESPAN_SPAN_TOOL_CALLS)) - if not isinstance(tool_calls, list) or not tool_calls: - return None primary_completion_message = _select_primary_completion_from_attrs(attrs) + has_llm_payload = bool(tool_calls) or any( + key in attrs + for key in ( + SpanAttributes.LLM_SYSTEM, + SpanAttributes.LLM_REQUEST_MODEL, + SpanAttributes.LLM_REQUEST_TYPE, + ) + ) or any( + key.startswith("gen_ai.usage.") or key.startswith("llm.usage.") + for key in attrs + ) + if primary_completion_message is None: + if not has_llm_payload: + return None + primary_completion_message = {"role": "assistant", "content": ""} + completion_text = _extract_text_from_message(primary_completion_message) - if completion_text in {None, ""}: + if completion_text is None: + completion_text = "" + if not completion_text and not has_llm_payload: return None span_context = span.get_span_context() @@ -228,12 +251,16 @@ def _build_claude_agent_final_chat_span( SpanAttributes.TRACELOOP_ENTITY_NAME: _ASSISTANT_MESSAGE_SPAN_NAME, f"{SpanAttributes.LLM_COMPLETIONS}.0.role": "assistant", f"{SpanAttributes.LLM_COMPLETIONS}.0.content": completion_text, - _COMPLETION_TOOL_CALLS_ATTR: tool_calls, SpanAttributes.TRACELOOP_ENTITY_OUTPUT: json.dumps( primary_completion_message, default=str, ), } + if isinstance(tool_calls, list) and tool_calls: + child_attributes[_COMPLETION_TOOL_CALLS_ATTR] = json.dumps( + tool_calls, + default=str, + ) input_value = attrs.get(SpanAttributes.TRACELOOP_ENTITY_INPUT) if input_value is not None: @@ -252,6 +279,17 @@ def _build_claude_agent_final_chat_span( for key, value in attrs.items() if key.startswith(_GEN_AI_PROMPT_PREFIX) }) + child_attributes.update({ + key: value + for key, value in attrs.items() + if key.startswith("gen_ai.usage.") + or key.startswith("llm.usage.") + or key.startswith("error.") + or key.startswith("exception.") + or key == SpanAttributes.LLM_REQUEST_FUNCTIONS + or (key.startswith("respan.") and key != RESPAN_LOG_TYPE) + or key == SpanAttributes.TRACELOOP_WORKFLOW_NAME + }) child_end_time = span.end_time child_start_time = span.start_time @@ -281,6 +319,31 @@ def _build_claude_agent_final_chat_span( ) +def _strip_claude_agent_llm_attributes(attrs: Mapping[str, Any]) -> Dict[str, Any]: + """Keep the exported agent parent on the common-span contract only.""" + llm_prefixes = ( + "gen_ai.prompt.", + "gen_ai.completion.", + "gen_ai.request.", + "gen_ai.response.", + "gen_ai.usage.", + "llm.request.", + "llm.response.", + "llm.usage.", + ) + llm_exact = { + SpanAttributes.LLM_SYSTEM, + "prompt_cache_hit_tokens", + "prompt_cache_creation_tokens", + } + return { + key: value + for key, value in attrs.items() + if key not in llm_exact + and not any(key.startswith(prefix) for prefix in llm_prefixes) + } + + def _prepare_spans_for_export(spans: Sequence[ReadableSpan]) -> List[ReadableSpan]: prepared_spans: List[ReadableSpan] = [] @@ -299,9 +362,22 @@ def _prepare_spans_for_export(spans: Sequence[ReadableSpan]) -> List[ReadableSpa if overrides else span ) + synthetic_child = _build_claude_agent_final_chat_span(prepared_span) + if ( + synthetic_child is not None + and prepared_span.attributes.get(RESPAN_LOG_TYPE) == LOG_TYPE_AGENT + ): + prepared_span = ModifiedSpan( + original_span=prepared_span, + overrides={ + OTEL_SPAN_ATTRIBUTES_FIELD: _strip_claude_agent_llm_attributes( + prepared_span.attributes or {} + ) + }, + ) + prepared_spans.append(prepared_span) - synthetic_child = _build_claude_agent_final_chat_span(prepared_span) if synthetic_child is not None: prepared_spans.append(synthetic_child) diff --git a/python-sdks/respan-tracing/tests/test_respan_exporter.py b/python-sdks/respan-tracing/tests/test_respan_exporter.py index c8db83d8..5b1890b4 100644 --- a/python-sdks/respan-tracing/tests/test_respan_exporter.py +++ b/python-sdks/respan-tracing/tests/test_respan_exporter.py @@ -501,18 +501,21 @@ def test_exporter_accepts_full_v2_traces_endpoint_without_duplication(): assert exporter._traces_url == "https://api.respan.ai/api/v2/traces" -def test_prepare_spans_adds_claude_agent_final_chat_child_for_tool_turn(): - """Claude Agent tool turns should emit a synthetic final child chat span.""" +def test_prepare_spans_splits_current_claude_agent_into_agent_and_chat(): + """Current Claude Agent spans export an agent parent plus canonical chat child.""" wrapper_span = _make_span( - name="ClaudeAgentSDK.query", + name="invoke_agent weather_agent", span_id=3002, attributes={ "respan.entity.log_type": "agent", "gen_ai.system": "anthropic", "gen_ai.request.model": "claude-sonnet-4-5", + "llm.request.type": "chat", "traceloop.entity.input": "Use the weather tool.", "traceloop.entity.output": "Tokyo is sunny and 22C.", + "gen_ai.usage.input_tokens": 12, + "gen_ai.usage.output_tokens": 8, "gen_ai.completion.0.tool_calls": json.dumps([ { "id": "call_1", @@ -524,25 +527,38 @@ def test_prepare_spans_adds_claude_agent_final_chat_child_for_tool_turn(): } ]), }, - scope_name="openinference.instrumentation.claude_agent_sdk", + scope_name="opentelemetry.instrumentation.claude_agent_sdk", ) wrapper_context = wrapper_span.get_span_context.return_value prepared = _prepare_spans_for_export(spans=[wrapper_span]) assert [s.name for s in prepared] == [ - "ClaudeAgentSDK.query", + "invoke_agent weather_agent", "assistant_message", ] + parent_attrs = prepared[0].attributes + assert parent_attrs["respan.entity.log_type"] == "agent" + assert "gen_ai.request.model" not in parent_attrs + assert "llm.request.type" not in parent_attrs + assert "gen_ai.usage.input_tokens" not in parent_attrs + assert "gen_ai.usage.output_tokens" not in parent_attrs + assert "gen_ai.completion.0.tool_calls" not in parent_attrs synthetic_child = prepared[1] assert synthetic_child.parent.span_id == wrapper_context.span_id assert synthetic_child.attributes["respan.entity.log_type"] == "chat" + assert synthetic_child.attributes["llm.request.type"] == "chat" + assert synthetic_child.attributes["gen_ai.request.model"] == "claude-sonnet-4-5" + assert synthetic_child.attributes["gen_ai.usage.input_tokens"] == 12 + assert synthetic_child.attributes["gen_ai.usage.output_tokens"] == 8 assert synthetic_child.attributes["gen_ai.completion.0.role"] == "assistant" assert ( synthetic_child.attributes["gen_ai.completion.0.content"] == "Tokyo is sunny and 22C." ) - assert synthetic_child.attributes["gen_ai.completion.0.tool_calls"] == [ + assert json.loads( + synthetic_child.attributes["gen_ai.completion.0.tool_calls"] + ) == [ { "id": "call_1", "type": "function", @@ -555,6 +571,58 @@ def test_prepare_spans_adds_claude_agent_final_chat_child_for_tool_turn(): assert synthetic_child.attributes["traceloop.entity.input"] == "Use the weather tool." +def test_prepare_spans_splits_tool_only_current_claude_agent(): + """A tool-only agent response still gets a canonical child chat span.""" + + tool_calls = [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "lookup_weather", + "arguments": '{"city":"Tokyo"}', + }, + } + ] + wrapper_span = _make_span( + name="invoke_agent weather_agent", + span_id=3003, + attributes={ + "respan.entity.log_type": "agent", + "gen_ai.system": "anthropic", + "gen_ai.request.model": "claude-sonnet-4-5", + "llm.request.type": "chat", + "traceloop.entity.input": "Use the weather tool.", + "traceloop.entity.output": json.dumps( + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "lookup_weather", + "input": {"city": "Tokyo"}, + } + ], + } + ), + "gen_ai.completion.0.tool_calls": json.dumps(tool_calls), + }, + scope_name="opentelemetry.instrumentation.claude_agent_sdk", + ) + + prepared = _prepare_spans_for_export(spans=[wrapper_span]) + + assert len(prepared) == 2 + assert prepared[0].attributes["respan.entity.log_type"] == "agent" + assert "llm.request.type" not in prepared[0].attributes + assert prepared[1].attributes["respan.entity.log_type"] == "chat" + assert prepared[1].attributes["gen_ai.completion.0.content"] == "" + assert json.loads( + prepared[1].attributes["gen_ai.completion.0.tool_calls"] + ) == tool_calls + + def test_prepare_spans_remaps_tool_call_helpers_and_strips_helper_attrs(): """Exporter remaps helper attrs to completion message fields before OTLP serialization."""