项目文件夹

文件
2026-07-13 13:32:05 +08:00

963 行
33 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""AgentCore × deepeval OTel SpanInterceptor.
Translates AWS Bedrock AgentCore / Strands / Traceloop spans into
``confident.*`` OTel attrs that ``ConfidentSpanExporter`` rebuilds into
deepeval ``BaseSpan``s. Mirrors the Pydantic AI POC pattern: pushes
``BaseSpan`` placeholders for ``update_current_span(...)``, an implicit
``Trace`` placeholder (``_is_otel_implicit=True``) for bare callers, consumes
``next_*_span(...)`` payloads at on_start, resolves trace attrs FRESH
at on_end, and stashes ``BaseMetric`` instances when evaluating.
Framework-specific extraction (Strands ``gen_ai.*`` events, Traceloop
attrs, AWS Bedrock body parsing) is framework-written and bypasses the
placeholder serializer.
"""
from __future__ import annotations
import contextvars
import json
import logging
from time import perf_counter
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from deepeval.config.settings import get_settings
from deepeval.tracing import perf_epoch_bridge as peb
from deepeval.tracing.context import (
apply_pending_to_span,
current_span_context,
current_trace_context,
pop_pending_for,
)
from deepeval.tracing.otel.utils import (
stash_pending_metrics,
to_hex_string,
)
from deepeval.tracing.perf_epoch_bridge import init_clock_bridge
from deepeval.tracing.tracing import trace_manager
from deepeval.tracing.integrations import Integration
from deepeval.tracing.utils import (
infer_provider_from_model,
normalize_span_provider_for_platform,
)
from deepeval.utils import serialize_to_json
from deepeval.tracing.types import (
AgentSpan,
BaseSpan,
Trace,
TraceSpanStatus,
ToolCall,
)
logger = logging.getLogger(__name__)
settings = get_settings()
try:
from opentelemetry.sdk.trace import (
ReadableSpan as _ReadableSpan,
SpanProcessor as _SpanProcessor,
)
dependency_installed = True
except ImportError as e:
dependency_installed = False
if settings.DEEPEVAL_VERBOSE_MODE:
logger.warning(
"Optional tracing dependency not installed: %s",
getattr(e, "name", repr(e)),
stacklevel=2,
)
class _SpanProcessor:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
def on_start(self, span: Any, parent_context: Any) -> None:
pass
def on_end(self, span: Any) -> None:
pass
class _ReadableSpan:
pass
def is_dependency_installed() -> bool:
if not dependency_installed:
raise ImportError(
"Dependencies are not installed. Please install them with "
"`pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http`."
)
return True
if TYPE_CHECKING:
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
else:
SpanProcessor = _SpanProcessor
ReadableSpan = _ReadableSpan
init_clock_bridge()
# Span classification: ``gen_ai.*`` (OTel GenAI semconv), Traceloop attrs,
# and span-name heuristics. Settings-independent; inspects raw OTel span only.
_AGENT_OP_NAMES = {"invoke_agent", "create_agent"}
_LLM_OP_NAMES = {
"chat",
"generate_content",
"invoke_model",
"text_completion",
"embeddings",
}
_TOOL_OP_NAMES = {"execute_tool"}
_TRACELOOP_KIND_MAP = {
"workflow": "agent",
"agent": "agent",
"task": "tool",
"tool": "tool",
"retriever": "retriever",
"llm": "llm",
}
def _get_attr(span, *keys: str) -> Optional[str]:
attrs = span.attributes or {}
for k in keys:
v = attrs.get(k)
if v:
return str(v)
return None
def _classify_span(span) -> Optional[str]:
attrs = span.attributes or {}
span_name_lower = (span.name or "").lower()
op_name = attrs.get("gen_ai.operation.name", "")
if op_name in _AGENT_OP_NAMES:
return "agent"
if op_name in _LLM_OP_NAMES:
return "llm"
if op_name in _TOOL_OP_NAMES:
return "tool"
traceloop_kind = attrs.get("traceloop.span.kind", "")
if traceloop_kind in _TRACELOOP_KIND_MAP:
return _TRACELOOP_KIND_MAP[traceloop_kind]
if attrs.get("gen_ai.tool.name") or attrs.get("gen_ai.tool.call.id"):
return "tool"
if attrs.get("gen_ai.agent.name") or attrs.get("gen_ai.agent.id"):
return "agent"
if any(kw in span_name_lower for kw in ("invoke_agent", "agent")):
return "agent"
if any(kw in span_name_lower for kw in ("execute_tool", ".tool")):
return "tool"
if any(kw in span_name_lower for kw in ("retriev", "memory", "datastore")):
return "retriever"
if any(
kw in span_name_lower
for kw in ("llm", "chat", "invoke_model", "generate")
):
return "llm"
return None
def _get_agent_name(span) -> Optional[str]:
return (
_get_attr(
span,
"gen_ai.agent.name",
"traceloop.entity.name",
"traceloop.workflow.name",
)
or span.name
or None
)
def _get_tool_name(span) -> Optional[str]:
return (
_get_attr(span, "gen_ai.tool.name", "traceloop.entity.name")
or span.name
or None
)
# Content / I/O extraction. Walks ``gen_ai.*`` events and Traceloop attrs to
# pull framework-written input/output text and tool calls.
def _parse_genai_content(raw: Any) -> Optional[str]:
if raw is None:
return None
if not isinstance(raw, str):
return str(raw)
try:
data = json.loads(raw)
if isinstance(data, list) and data:
first = data[0]
if isinstance(first, dict):
return first.get("text") or first.get("content") or str(first)
return str(first)
if isinstance(data, dict):
return data.get("text") or data.get("content") or str(data)
return str(data)
except (json.JSONDecodeError, TypeError):
return raw
def _extract_messages(span) -> tuple[Optional[str], Optional[str]]:
input_text: Optional[str] = None
output_text: Optional[str] = None
# Events (Strands / strict OTel GenAI)
for event in getattr(span, "events", []):
event_name = event.name or ""
event_attrs = event.attributes or {}
if event_name == "gen_ai.user.message":
input_text = _parse_genai_content(event_attrs.get("content"))
elif event_name in ("gen_ai.choice", "gen_ai.assistant.message"):
output_text = _parse_genai_content(
event_attrs.get("message") or event_attrs.get("content")
)
elif event_name == "gen_ai.system.message":
if not input_text:
input_text = _parse_genai_content(event_attrs.get("content"))
elif event_name in (
"gen_ai.client.inference.operation.details",
"agent.invocation",
"tool.invocation",
):
body_raw = event_attrs.get("body") or event_attrs.get("event.body")
if body_raw:
try:
body = (
json.loads(body_raw)
if isinstance(body_raw, str)
else body_raw
)
if not input_text and "input" in body:
msgs = body["input"].get("messages", [])
if msgs:
input_text = _parse_genai_content(
msgs[-1].get("content")
if isinstance(msgs[-1], dict)
else msgs[-1]
)
if not output_text and "output" in body:
msgs = body["output"].get("messages", [])
if msgs:
output_text = _parse_genai_content(
msgs[-1].get("content")
if isinstance(msgs[-1], dict)
else msgs[-1]
)
except Exception:
pass
# Fallback: attributes (LangChain / CrewAI / Traceloop)
if not input_text:
raw = _get_attr(
span,
"gen_ai.user.message",
"gen_ai.input.messages",
"gen_ai.prompt",
"traceloop.entity.input",
"crewai.task.description",
)
if raw:
input_text = _parse_genai_content(raw)
if not output_text:
raw = _get_attr(
span,
"gen_ai.choice",
"gen_ai.output.messages",
"gen_ai.completion",
"traceloop.entity.output",
)
if raw:
output_text = _parse_genai_content(raw)
return input_text, output_text
def _extract_tool_calls(span) -> List[ToolCall]:
tools: List[ToolCall] = []
# Events (Strands / strict OTel)
for event in getattr(span, "events", []):
event_attrs = event.attributes or {}
event_name = event.name or ""
if event_name in ("gen_ai.tool.call", "tool_call", "execute_tool"):
try:
name = (
event_attrs.get("gen_ai.tool.name")
or event_attrs.get("name")
or "unknown_tool"
)
args_raw = (
event_attrs.get("gen_ai.tool.call.arguments")
or event_attrs.get("gen_ai.tool.arguments")
or event_attrs.get("input")
or "{}"
)
input_params = (
json.loads(args_raw)
if isinstance(args_raw, str)
else args_raw
)
tools.append(
ToolCall(name=str(name), input_parameters=input_params)
)
except Exception as exc:
logger.debug("Failed to parse tool call event: %s", exc)
# Fallback: attributes (LangChain / CrewAI / Traceloop)
attrs = span.attributes or {}
tool_calls_raw = (
attrs.get("gen_ai.tool.calls")
or attrs.get("traceloop.tool_calls")
or attrs.get("llm.tool_calls")
)
if tool_calls_raw:
try:
calls = (
json.loads(tool_calls_raw)
if isinstance(tool_calls_raw, str)
else tool_calls_raw
)
if isinstance(calls, list):
for call in calls:
# Traceloop / OpenLLMetry nest these under "function".
name = (
call.get("name")
or call.get("function", {}).get("name")
or "unknown_tool"
)
args = (
call.get("arguments")
or call.get("function", {}).get("arguments")
or "{}"
)
input_params = (
json.loads(args) if isinstance(args, str) else args
)
tools.append(
ToolCall(name=str(name), input_parameters=input_params)
)
except Exception as exc:
logger.debug("Failed to parse tool call attributes: %s", exc)
return tools
def _extract_tool_call_from_tool_span(span) -> Optional[ToolCall]:
tool_name = _get_tool_name(span)
if not tool_name:
return None
attrs = span.attributes or {}
args_raw = (
attrs.get("gen_ai.tool.call.arguments")
or attrs.get("traceloop.entity.input")
or "{}"
)
try:
input_params = (
json.loads(args_raw) if isinstance(args_raw, str) else args_raw
)
except Exception:
input_params = {}
return ToolCall(name=tool_name, input_parameters=input_params)
# Settings: trace-level kwargs only. Span-level config goes on
# ``next_*_span(...)`` / ``update_current_span(...)`` — see README.
class AgentCoreInstrumentationSettings:
"""Trace-level defaults for AgentCore instrumentation.
All kwargs are optional. Trace fields are resolved at every span's
``on_end`` so runtime ``update_current_trace(...)`` mutations win.
``api_key`` is optional; when omitted, the OTel pipeline runs
locally but the Confident AI backend rejects uploads.
"""
# Span-level kwargs removed in the OTel POC migration — raise on use.
_REMOVED_KWARGS = (
"is_test_mode",
"agent_metric_collection",
"llm_metric_collection",
"tool_metric_collection_map",
"trace_metric_collection",
"agent_metrics",
"confident_prompt",
)
def __init__(
self,
api_key: Optional[str] = None,
name: Optional[str] = None,
thread_id: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[dict] = None,
tags: Optional[List[str]] = None,
metric_collection: Optional[str] = None,
test_case_id: Optional[str] = None,
turn_id: Optional[str] = None,
environment: Optional[str] = None,
**removed_kwargs: Any,
):
is_dependency_installed()
# ``**removed_kwargs`` exists only to produce a crisp migration error.
if removed_kwargs:
offending = ", ".join(sorted(removed_kwargs))
raise TypeError(
f"AgentCoreInstrumentationSettings: unexpected keyword "
f"argument(s) {offending}. Span-level kwargs were removed "
"in the OTel POC migration; use ``with next_*_span(...)`` "
"or ``update_current_span(...)``. "
"See deepeval/integrations/README.md."
)
if trace_manager.environment is not None:
_env = trace_manager.environment
elif environment is not None:
_env = environment
elif settings.CONFIDENT_TRACE_ENVIRONMENT is not None:
_env = settings.CONFIDENT_TRACE_ENVIRONMENT
else:
_env = "development"
if _env not in ("production", "staging", "development", "testing"):
_env = "development"
self.environment = _env
self.api_key = api_key
self.name = name
self.thread_id = thread_id
self.user_id = user_id
self.metadata = metadata
self.tags = tags
self.metric_collection = metric_collection
self.test_case_id = test_case_id
self.turn_id = turn_id
# Span interceptor. Pushes BaseSpan placeholders for ``update_current_span``,
# implicit Trace for bare callers, parent-uuid bridge for OTel roots inside
# ``@observe``, ``next_*_span`` consumption, and framework-attr extraction.
class AgentCoreSpanInterceptor(SpanProcessor):
def __init__(self, settings_instance: AgentCoreInstrumentationSettings):
self.settings = settings_instance
# Per-OTel-span state keyed by span_id (unique within a process).
self._tokens: Dict[int, contextvars.Token] = {}
self._placeholders: Dict[int, BaseSpan] = {}
# Implicit-trace state, keyed on the OTel root span_id that pushed it.
self._trace_tokens: Dict[int, contextvars.Token] = {}
self._trace_placeholders: Dict[int, Trace] = {}
def on_start(self, span, parent_context):
# Order matches Pydantic AI: implicit-trace push before classification
# so anything reading ``current_trace_context`` downstream sees it.
self._maybe_push_implicit_trace_context(span)
self._maybe_bridge_otel_root_to_deepeval_parent(span)
span_type = _classify_span(span)
if span_type:
try:
span.set_attribute("confident.span.type", span_type)
except Exception:
pass
# Stamp name at on_start because the placeholder subclass depends on it.
if span_type == "agent":
agent_name = _get_agent_name(span)
if agent_name:
try:
span.set_attribute("confident.span.name", agent_name)
except Exception:
pass
elif span_type == "tool":
tool_name = _get_tool_name(span)
if tool_name:
try:
span.set_attribute("confident.span.name", tool_name)
except Exception:
pass
self._push_span_context(span, span_type)
def on_end(self, span):
sid = span.get_span_context().span_id
# Resolve trace attrs FRESH so live ``update_current_trace(...)`` wins.
try:
self._serialize_trace_context_to_otel_attrs(span)
except Exception as exc:
logger.debug(
"Failed to serialize trace context for span_id=%s: %s",
sid,
exc,
)
placeholder = self._placeholders.pop(sid, None)
token = self._tokens.pop(sid, None)
if token is not None:
try:
current_span_context.reset(token)
except Exception as exc:
logger.debug(
"Failed to reset current_span_context for span_id=%s: %s",
sid,
exc,
)
if placeholder is not None:
try:
self._serialize_placeholder_to_otel_attrs(placeholder, span)
except Exception as exc:
logger.debug(
"Failed to serialize span placeholder for span_id=%s: %s",
sid,
exc,
)
try:
if placeholder.metrics and trace_manager.is_evaluating:
stash_pending_metrics(
to_hex_string(sid, 16), placeholder.metrics
)
except Exception as exc:
logger.debug(
"Failed to stash pending metrics for span_id=%s: %s",
sid,
exc,
)
# Framework attrs are non-user-mutable; written alongside (not inside)
# the placeholder serializer.
try:
self._serialize_framework_attrs(span)
except Exception as exc:
logger.debug(
"Failed to serialize framework attrs for span_id=%s: %s",
sid,
exc,
)
# Must run AFTER trace serialization so the implicit placeholder's
# mutations land on this root's attrs.
self._maybe_pop_implicit_trace_context(span)
def _push_span_context(self, span, span_type: Optional[str]) -> None:
"""Push a ``BaseSpan`` / ``AgentSpan`` placeholder onto the contextvar.
Consumes ``next_*_span(...)`` defaults BEFORE the push so user code
sees the staged values.
"""
try:
sid = span.get_span_context().span_id
tid = span.get_span_context().trace_id
start_time = (
peb.epoch_nanos_to_perf_seconds(span.start_time)
if span.start_time
else perf_counter()
)
kwargs: Dict[str, Any] = dict(
uuid=to_hex_string(sid, 16),
trace_uuid=to_hex_string(tid, 32),
status=TraceSpanStatus.IN_PROGRESS,
start_time=start_time,
)
if span_type == "agent":
# Reuse the on_start-stamped name to skip a duplicate lookup.
attrs = span.attributes or {}
placeholder = AgentSpan(
name=(
attrs.get("confident.span.name")
or _get_agent_name(span)
or "agent"
),
**kwargs,
)
else:
placeholder = BaseSpan(**kwargs)
pending = pop_pending_for(span_type)
if pending:
apply_pending_to_span(placeholder, pending)
token = current_span_context.set(placeholder)
self._tokens[sid] = token
self._placeholders[sid] = placeholder
except Exception as exc:
logger.debug(
"Failed to push current_span_context placeholder: %s", exc
)
def _maybe_push_implicit_trace_context(self, span) -> None:
"""Push an implicit ``Trace`` for OTel roots without enclosing context.
Tagged ``_is_otel_implicit=True`` so ``ContextAwareSpanProcessor``
still routes to OTLP. ``_is_otel_implicit`` is a Pydantic
``PrivateAttr``, so it must be set after construction (it's not a
constructor kwarg).
"""
if current_trace_context.get() is not None:
return
if getattr(span, "parent", None) is not None:
return
try:
sid = span.get_span_context().span_id
tid = span.get_span_context().trace_id
start_time = (
peb.epoch_nanos_to_perf_seconds(span.start_time)
if span.start_time
else perf_counter()
)
implicit = Trace(
uuid=to_hex_string(tid, 32),
root_spans=[],
status=TraceSpanStatus.IN_PROGRESS,
start_time=start_time,
)
implicit._is_otel_implicit = True
token = current_trace_context.set(implicit)
self._trace_tokens[sid] = token
self._trace_placeholders[sid] = implicit
except Exception as exc:
logger.debug(
"Failed to push implicit current_trace_context: %s", exc
)
def _maybe_bridge_otel_root_to_deepeval_parent(self, span) -> None:
"""Re-parent OTel roots onto an enclosing ``@observe`` deepeval span.
Stamps ``confident.span.parent_uuid`` so the exporter stitches the
OTel root into the deepeval parent's trace instead of leaving them
as siblings.
"""
if getattr(span, "parent", None) is not None:
return
parent_span = current_span_context.get()
if parent_span is None:
return
parent_uuid = getattr(parent_span, "uuid", None)
if not parent_uuid:
return
try:
self._set_attr_post_end(
span, "confident.span.parent_uuid", parent_uuid
)
except Exception as exc:
logger.debug(
"Failed to bridge OTel root span to deepeval parent "
"(parent_uuid=%s): %s",
parent_uuid,
exc,
)
def _maybe_pop_implicit_trace_context(self, span) -> None:
try:
sid = span.get_span_context().span_id
except Exception:
return
token = self._trace_tokens.pop(sid, None)
self._trace_placeholders.pop(sid, None)
if token is None:
return
try:
current_trace_context.reset(token)
except Exception as exc:
logger.debug(
"Failed to reset implicit current_trace_context for "
"span_id=%s: %s",
sid,
exc,
)
@staticmethod
def _set_attr_post_end(span, key: str, value: Any) -> None:
"""Write to a span that may have ended.
``Span.set_attribute`` is a no-op after ``Span.end()``, so we write
directly through ``_attributes`` (mutable while processors are
running) and fall back to ``set_attribute`` if that fails.
"""
try:
attrs = getattr(span, "_attributes", None)
if attrs is not None:
attrs[key] = value
return
except Exception as exc:
logger.debug(
"Direct _attributes write failed for %s; "
"falling back to set_attribute (may be dropped): %s",
key,
exc,
)
try:
span.set_attribute(key, value)
except Exception as exc:
logger.debug("set_attribute fallback failed for %s: %s", key, exc)
@classmethod
def _serialize_placeholder_to_otel_attrs(
cls, placeholder: BaseSpan, span
) -> None:
"""Mirror ``update_current_span`` writes onto ``confident.span.*``.
Only writes user-set fields; doesn't overwrite on_start-stamped attrs.
"""
existing = span.attributes or {}
if placeholder.metadata:
cls._set_attr_post_end(
span,
"confident.span.metadata",
serialize_to_json(placeholder.metadata),
)
if placeholder.input is not None:
cls._set_attr_post_end(
span,
"confident.span.input",
serialize_to_json(placeholder.input),
)
if placeholder.output is not None:
cls._set_attr_post_end(
span,
"confident.span.output",
serialize_to_json(placeholder.output),
)
if placeholder.metric_collection:
cls._set_attr_post_end(
span,
"confident.span.metric_collection",
placeholder.metric_collection,
)
if placeholder.retrieval_context:
cls._set_attr_post_end(
span,
"confident.span.retrieval_context",
serialize_to_json(placeholder.retrieval_context),
)
if placeholder.context:
cls._set_attr_post_end(
span,
"confident.span.context",
serialize_to_json(placeholder.context),
)
if placeholder.expected_output:
cls._set_attr_post_end(
span,
"confident.span.expected_output",
placeholder.expected_output,
)
if placeholder.name and not existing.get("confident.span.name"):
cls._set_attr_post_end(
span, "confident.span.name", placeholder.name
)
def _serialize_trace_context_to_otel_attrs(self, span) -> None:
"""Resolve trace attrs FRESH and write to ``confident.trace.*``.
Reads ``current_trace_context.get()`` (so live
``update_current_trace(...)`` mutations win) with
``self.settings.*`` as fallback. Metadata is settings-base merged
with runtime context on top.
"""
trace_ctx = current_trace_context.get()
_name = (trace_ctx.name if trace_ctx else None) or self.settings.name
_thread_id = (
trace_ctx.thread_id if trace_ctx else None
) or self.settings.thread_id
_user_id = (
trace_ctx.user_id if trace_ctx else None
) or self.settings.user_id
_tags = (trace_ctx.tags if trace_ctx else None) or self.settings.tags
_test_case_id = (
trace_ctx.test_case_id if trace_ctx else None
) or self.settings.test_case_id
_turn_id = (
trace_ctx.turn_id if trace_ctx else None
) or self.settings.turn_id
_trace_metric_collection = (
trace_ctx.metric_collection if trace_ctx else None
) or self.settings.metric_collection
_metadata = {
**(self.settings.metadata or {}),
**((trace_ctx.metadata or {}) if trace_ctx else {}),
}
if _name:
self._set_attr_post_end(span, "confident.trace.name", _name)
if _thread_id:
self._set_attr_post_end(
span, "confident.trace.thread_id", _thread_id
)
if _user_id:
self._set_attr_post_end(span, "confident.trace.user_id", _user_id)
if _tags:
self._set_attr_post_end(span, "confident.trace.tags", _tags)
if _metadata:
self._set_attr_post_end(
span,
"confident.trace.metadata",
serialize_to_json(_metadata),
)
if _trace_metric_collection:
self._set_attr_post_end(
span,
"confident.trace.metric_collection",
_trace_metric_collection,
)
if _test_case_id:
self._set_attr_post_end(
span, "confident.trace.test_case_id", _test_case_id
)
if _turn_id:
self._set_attr_post_end(span, "confident.trace.turn_id", _turn_id)
if self.settings.environment:
self._set_attr_post_end(
span,
"confident.trace.environment",
self.settings.environment,
)
# Default thread_id from Strands' ``session.id`` if nothing else set it.
if not (span.attributes or {}).get("confident.trace.thread_id"):
session_id = (span.attributes or {}).get("session.id")
if session_id:
self._set_attr_post_end(
span, "confident.trace.thread_id", session_id
)
def _serialize_framework_attrs(self, span) -> None:
"""Translate Strands / Traceloop / GenAI attrs into ``confident.*``.
Uses ``setdefault`` semantics — the placeholder serializer ran first,
so user mutations win.
"""
attrs = span.attributes or {}
span_type = attrs.get("confident.span.type") or _classify_span(span)
if span_type and "confident.span.type" not in attrs:
self._set_attr_post_end(span, "confident.span.type", span_type)
if not attrs.get("confident.span.integration"):
self._set_attr_post_end(
span, "confident.span.integration", Integration.AGENTCORE.value
)
input_text, output_text = _extract_messages(span)
if input_text and "confident.span.input" not in attrs:
self._set_attr_post_end(span, "confident.span.input", input_text)
if span_type == "agent":
self._set_attr_post_end(
span, "confident.trace.input", input_text
)
if output_text and "confident.span.output" not in attrs:
self._set_attr_post_end(span, "confident.span.output", output_text)
if span_type == "agent":
self._set_attr_post_end(
span, "confident.trace.output", output_text
)
input_tokens = attrs.get("gen_ai.usage.input_tokens") or attrs.get(
"gen_ai.usage.prompt_tokens"
)
output_tokens = attrs.get("gen_ai.usage.output_tokens") or attrs.get(
"gen_ai.usage.completion_tokens"
)
if input_tokens is not None:
self._set_attr_post_end(
span, "confident.llm.input_token_count", int(input_tokens)
)
if output_tokens is not None:
self._set_attr_post_end(
span, "confident.llm.output_token_count", int(output_tokens)
)
model = _get_attr(
span,
"gen_ai.response.model",
"gen_ai.request.model",
)
if model:
self._set_attr_post_end(span, "confident.llm.model", model)
if span_type == "llm" and not attrs.get("confident.span.provider"):
provider = infer_provider_from_model(model)
if provider:
provider = normalize_span_provider_for_platform(provider)
self._set_attr_post_end(
span, "confident.span.provider", provider
)
tools_called: List[ToolCall] = []
if span_type == "agent":
tools_called = _extract_tool_calls(span)
tool_defs_raw = attrs.get("gen_ai.tool.definitions") or attrs.get(
"gen_ai.agent.tools"
)
if tool_defs_raw:
self._set_attr_post_end(
span,
"confident.agent.tool_definitions",
str(tool_defs_raw),
)
elif span_type == "tool":
tc = _extract_tool_call_from_tool_span(span)
if tc:
tools_called = [tc]
if tc.input_parameters and "confident.span.input" not in attrs:
self._set_attr_post_end(
span,
"confident.span.input",
serialize_to_json(tc.input_parameters),
)
if "confident.span.output" not in attrs:
raw_output = _get_attr(
span, "traceloop.entity.output", "gen_ai.tool.output"
)
if raw_output:
self._set_attr_post_end(
span, "confident.span.output", raw_output
)
if tools_called:
self._set_attr_post_end(
span,
"confident.span.tools_called",
[t.model_dump_json() for t in tools_called],
)
if span_type == "agent" and "confident.span.name" not in attrs:
agent_name = _get_agent_name(span)
if agent_name:
self._set_attr_post_end(span, "confident.span.name", agent_name)