Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 12 additions & 31 deletions src/agentevals/api/otlp_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
)

from ..extraction import flatten_otlp_attributes
from ..otlp_anyvalue import decode_any_value
from ..trace_attrs import (
AGENTEVALS_EVAL_SET_ID,
AGENTEVALS_SESSION_NAME,
OTEL_GENAI_CONVERSATION_ID,
OTEL_GENAI_INPUT_MESSAGES,
OTEL_GENAI_OUTPUT_MESSAGES,
Expand All @@ -30,9 +33,6 @@

logger = logging.getLogger(__name__)

AGENTEVALS_EVAL_SET_ID = "agentevals.eval_set_id"
AGENTEVALS_SESSION_NAME = "agentevals.session_name"


async def process_traces(body: dict, manager: StreamingTraceManager) -> None:
"""Parse ExportTraceServiceRequest and feed spans to the pipeline."""
Expand Down Expand Up @@ -242,7 +242,13 @@ def _normalize_span(span_data: dict, scope_name: str, scope_version: str, schema


def _extract_agentevals_metadata(resource_attrs: list[dict]) -> dict:
"""Extract agentevals-specific metadata from OTLP resource attributes."""
"""Extract agentevals-specific metadata from OTLP resource attributes.

``eval_set_id`` and ``session_name`` are used as dict keys downstream
(``_active_session_for_name``) and typed ``str | None`` on the session
models. Neither is in ``SPEC_CONTAINER_ATTRS``, so the shared decoder never
hands them a list or dict in the first place.
"""
flat = flatten_otlp_attributes(resource_attrs)
return {
"eval_set_id": flat.get(AGENTEVALS_EVAL_SET_ID),
Expand Down Expand Up @@ -310,37 +316,12 @@ def _convert_otlp_log_record(log_record: dict) -> dict | None:
return result


def _parse_otlp_any_value(value_obj: dict):
"""Recursively parse an OTLP AnyValue to native Python types.

Handles the full AnyValue union: stringValue, intValue, doubleValue,
boolValue, kvlistValue (→ dict), arrayValue (→ list), bytesValue.
"""
if "stringValue" in value_obj:
return value_obj["stringValue"]
if "intValue" in value_obj:
return int(value_obj["intValue"])
if "doubleValue" in value_obj:
return float(value_obj["doubleValue"])
if "boolValue" in value_obj:
return value_obj["boolValue"]
if "kvlistValue" in value_obj:
kv = value_obj["kvlistValue"]
return {item.get("key", ""): _parse_otlp_any_value(item.get("value", {})) for item in kv.get("values", [])}
if "arrayValue" in value_obj:
arr = value_obj["arrayValue"]
return [_parse_otlp_any_value(v) for v in arr.get("values", [])]
if "bytesValue" in value_obj:
return value_obj["bytesValue"]
return value_obj


def _parse_otlp_body(body_raw: dict) -> dict | str:
"""Parse OTLP log record body value.

Top-level stringValue bodies are JSON-decoded (Strands-style logs store
message content as JSON strings). All other AnyValue types are parsed
recursively via ``_parse_otlp_any_value`` (handles the nested kvlistValue /
recursively via ``decode_any_value`` (handles the nested kvlistValue /
arrayValue structures used by the OpenAI instrumentor).
"""
if "stringValue" in body_raw:
Expand All @@ -351,4 +332,4 @@ def _parse_otlp_body(body_raw: dict) -> dict | str:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return raw
return _parse_otlp_any_value(body_raw)
return decode_any_value(body_raw)
21 changes: 7 additions & 14 deletions src/agentevals/extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from typing import Any, Protocol, TypedDict, TypeVar

from .loader.base import Span, Trace
from .otlp_anyvalue import decode_attributes
from .trace_attrs import (
ADK_LLM_REQUEST,
ADK_LLM_RESPONSE,
Expand Down Expand Up @@ -523,20 +524,12 @@ def is_invocation_span(span: Span) -> bool:


def flatten_otlp_attributes(attrs_list: list[dict]) -> dict[str, Any]:
"""Convert OTLP attributes array [{key, value: {stringValue|...}}] to flat dict."""
result: dict[str, Any] = {}
for attr in attrs_list:
key = attr.get("key", "")
value_obj = attr.get("value", {})
if "stringValue" in value_obj:
result[key] = value_obj["stringValue"]
elif "intValue" in value_obj:
result[key] = int(value_obj["intValue"])
elif "doubleValue" in value_obj:
result[key] = float(value_obj["doubleValue"])
elif "boolValue" in value_obj:
result[key] = value_obj["boolValue"]
return result
"""Convert OTLP attributes array [{key, value: {stringValue|...}}] to flat dict.

Delegates to the shared ``AnyValue`` decoder so array/kvlist/bytes
attributes survive instead of being dropped.
"""
return decode_attributes(attrs_list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the widening. span.tags now carries lists into every consumer, and the same unhashable crash as session_name survives at other places as well.



# ---------------------------------------------------------------------------
Expand Down
27 changes: 6 additions & 21 deletions src/agentevals/loader/otlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import logging

from ..otlp_anyvalue import decode_attribute, decode_attributes, is_any_value
from ..trace_attrs import (
OTEL_GENAI_INPUT_MESSAGES,
OTEL_GENAI_OUTPUT_MESSAGES,
Expand Down Expand Up @@ -175,8 +176,10 @@ def _promote_genai_event_attributes(self, span_data: dict, attributes: dict) ->
key = attr.get("key", "")
if key in self._GENAI_EVENT_KEYS and key not in attributes:
value_obj = attr.get("value", {})
if "stringValue" in value_obj:
attributes[key] = value_obj["stringValue"]
if is_any_value(value_obj):
keep, value = decode_attribute(key, value_obj)
if keep:
attributes[key] = value

def _extract_attributes(self, attrs) -> dict:
"""Convert attributes to a flat ``{key: value}`` dict.
Expand All @@ -192,25 +195,7 @@ def _extract_attributes(self, attrs) -> dict:
if isinstance(attrs, dict):
return self._flatten_nested_dict(attrs)

result = {}
for attr in attrs:
key = attr.get("key", "")
value_obj = attr.get("value", {})

if "stringValue" in value_obj:
result[key] = value_obj["stringValue"]
elif "intValue" in value_obj:
result[key] = int(value_obj["intValue"])
elif "doubleValue" in value_obj:
result[key] = float(value_obj["doubleValue"])
elif "boolValue" in value_obj:
result[key] = value_obj["boolValue"]
elif "arrayValue" in value_obj:
result[key] = json.dumps(value_obj["arrayValue"])
elif "kvlistValue" in value_obj:
result[key] = json.dumps(value_obj["kvlistValue"])

return result
return decode_attributes(attrs)

@staticmethod
def _flatten_nested_dict(d: dict, prefix: str = "") -> dict:
Expand Down
118 changes: 118 additions & 0 deletions src/agentevals/otlp_anyvalue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Shared decoder for the OTLP ``AnyValue`` union.

OTLP encodes every attribute value, log body and nested element as an
``AnyValue``: a one-of wrapper such as ``{"stringValue": "chat"}`` or
``{"arrayValue": {"values": [...]}}``. The protobuf receiver (via
``MessageToDict``) and OTLP/JSON payloads deliver that same dict shape, so
every consumer needs identical decoding rules.

This module depends only on the standard library and ``trace_attrs`` (a leaf
constants module), so ``extraction``, ``loader.otlp`` and ``api.otlp_processing``
can all use it without creating an import cycle.
"""

from __future__ import annotations

import logging
from typing import Any

from .trace_attrs import SPEC_CONTAINER_ATTRS

logger = logging.getLogger(__name__)

ANY_VALUE_FIELDS = (
"stringValue",
"intValue",
"doubleValue",
"boolValue",
"kvlistValue",
"arrayValue",
"bytesValue",
)


def decode_any_value(value_obj: dict) -> Any:
"""Recursively decode an OTLP ``AnyValue`` to a native Python value.

Handles the full union: stringValue, intValue (OTLP sends it as a
string), doubleValue, boolValue, kvlistValue (→ dict), arrayValue
(→ list), bytesValue.

``bytesValue`` is returned unchanged. ``MessageToDict`` base64-encodes
protobuf bytes fields and OTLP/JSON does the same, so callers already
receive a str; decoding it here would change the value they see today.

A value carrying none of the union fields is returned as-is.
"""
if "stringValue" in value_obj:
return value_obj["stringValue"]
if "intValue" in value_obj:
return int(value_obj["intValue"])
if "doubleValue" in value_obj:
return float(value_obj["doubleValue"])
if "boolValue" in value_obj:
return value_obj["boolValue"]
if "kvlistValue" in value_obj:
kv = value_obj["kvlistValue"]
return {item.get("key", ""): decode_any_value(item.get("value", {})) for item in kv.get("values", [])}
if "arrayValue" in value_obj:
arr = value_obj["arrayValue"]
return [decode_any_value(v) for v in arr.get("values", [])]
if "bytesValue" in value_obj:
return value_obj["bytesValue"]
return value_obj


def is_any_value(value_obj: dict) -> bool:
"""Return True when *value_obj* carries one of the ``AnyValue`` fields."""
for field in ANY_VALUE_FIELDS:
if field in value_obj:
return True
return False


def decode_attribute(key: str, value_obj: dict) -> tuple[bool, Any]:
"""Decode one attribute, applying the container allowlist.

Returns ``(keep, value)``. Scalars are always kept. A list or dict is kept
only when *key* is in :data:`SPEC_CONTAINER_ATTRS`; otherwise it is dropped,
which is what ``extraction.py`` did with containers before this decoder was
shared.

Dropping rather than serialising is deliberate: JSON-dumping the value would
put a blob back into user-visible output, which is the symptom #173 is
about. What the default *should* be is tracked in #208.
"""
value = decode_any_value(value_obj)
if isinstance(value, (list, dict)) and key not in SPEC_CONTAINER_ATTRS:
logger.warning(
"Dropping container value for %s (got %s); only spec container attributes are kept",
key,
type(value).__name__,
)
return False, None
return True, value


def decode_attributes(attrs_list: list[dict]) -> dict[str, Any]:
"""Decode an OTLP attributes array to a flat ``{key: value}`` dict.

Entries whose value carries no ``AnyValue`` field are skipped, matching the
behaviour every call site had before they shared this decoder.

Container values survive only for the keys in
:data:`~agentevals.trace_attrs.SPEC_CONTAINER_ATTRS`. Keeping the allowlist
here rather than narrowing per consumer means an attribute nobody has
thought about cannot become an unhashable dict key downstream - there is
nothing to remember, because it was never widened in the first place.
"""
result: dict[str, Any] = {}
for attr in attrs_list:
value_obj = attr.get("value", {})
if not is_any_value(value_obj):
continue
key = attr.get("key", "")
keep, value = decode_attribute(key, value_obj)
if keep:
result[key] = value
return result
30 changes: 30 additions & 0 deletions src/agentevals/trace_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,33 @@

# agentevals custom attributes (repository-specific, outside OTel semconv)
AGENTEVALS_SESSION_ID = "agentevals.session_id"
AGENTEVALS_EVAL_SET_ID = "agentevals.eval_set_id"
AGENTEVALS_SESSION_NAME = "agentevals.session_name"

# Attributes the GenAI semconv types as arrays or structured values. These are
# the only keys the shared AnyValue decoder returns as a native list or dict;
# every other key is decoded to a scalar or dropped, which is what
# ``extraction.py`` did before the decoder was shared.
#
# The set is an allowlist on purpose. A missing entry drops one value, matching
# the pre-existing extraction behaviour; a denylist would instead let an
# unhashable value reach a dict key or set member on an unauthenticated
# receiver port, which crashes ingestion. See the discussion on #187.
#
# Hand-maintained: opentelemetry-semantic-conventions exposes constants and
# prose docstrings only, so attribute value types are not machine-readable and
# this set cannot be derived from the package.
#
# What should happen to container values on keys outside this set is not
# settled; today they are dropped. Tracked in #208.
SPEC_CONTAINER_ATTRS: frozenset[str] = frozenset(
{
OTEL_GENAI_RESPONSE_FINISH_REASONS,
OTEL_GENAI_INPUT_MESSAGES,
OTEL_GENAI_OUTPUT_MESSAGES,
OTEL_GENAI_TOOL_DEFINITIONS,
OTEL_GENAI_SYSTEM_INSTRUCTIONS,
OTEL_GENAI_TOOL_CALL_ARGUMENTS,
OTEL_GENAI_TOOL_CALL_RESULT,
}
)
Loading