Skip to content
Open
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
30 changes: 3 additions & 27 deletions src/agentevals/api/otlp_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
)

from ..extraction import flatten_otlp_attributes
from ..otlp_anyvalue import decode_any_value
from ..trace_attrs import (
OTEL_GENAI_CONVERSATION_ID,
OTEL_GENAI_INPUT_MESSAGES,
Expand Down Expand Up @@ -310,37 +311,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 +327,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)


# ---------------------------------------------------------------------------
Expand Down
21 changes: 2 additions & 19 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_attributes
from ..trace_attrs import (
OTEL_GENAI_INPUT_MESSAGES,
OTEL_GENAI_OUTPUT_MESSAGES,
Expand Down Expand Up @@ -192,25 +193,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
80 changes: 80 additions & 0 deletions src/agentevals/otlp_anyvalue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""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 is deliberately dependency-free: importing only the standard
library lets ``extraction``, ``loader.otlp`` and ``api.otlp_processing`` all
use it without creating an import cycle.
"""

from __future__ import annotations

from typing import Any

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_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.
"""
result: dict[str, Any] = {}
for attr in attrs_list:
value_obj = attr.get("value", {})
if is_any_value(value_obj):
result[attr.get("key", "")] = decode_any_value(value_obj)
return result
52 changes: 52 additions & 0 deletions tests/test_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,58 @@ def test_mixed_types(self):
)
assert result == {"str": "hello", "num": 3.14, "flag": True}

def test_array_value(self):
result = flatten_otlp_attributes(
[
{
"key": "gen_ai.response.finish_reasons",
"value": {"arrayValue": {"values": [{"stringValue": "stop"}]}},
},
]
)
assert result == {"gen_ai.response.finish_reasons": ["stop"]}

def test_kvlist_value(self):
result = flatten_otlp_attributes(
[
{
"key": "gen_ai.request.params",
"value": {
"kvlistValue": {
"values": [
{"key": "temperature", "value": {"doubleValue": 0.7}},
{"key": "stream", "value": {"boolValue": False}},
]
}
},
},
]
)
assert result == {"gen_ai.request.params": {"temperature": 0.7, "stream": False}}

def test_array_of_kvlist(self):
"""Tool calls arrive as an arrayValue of kvlistValue."""
result = flatten_otlp_attributes(
[
{
"key": "gen_ai.tool.calls",
"value": {
"arrayValue": {
"values": [
{"kvlistValue": {"values": [{"key": "name", "value": {"stringValue": "get_weather"}}]}},
]
}
},
},
]
)
assert result == {"gen_ai.tool.calls": [{"name": "get_weather"}]}

def test_bytes_value(self):
"""MessageToDict base64-encodes bytes fields, so the decoder sees a str."""
result = flatten_otlp_attributes([{"key": "payload", "value": {"bytesValue": "AP9oaQ=="}}])
assert result == {"payload": "AP9oaQ=="}

def test_empty(self):
assert flatten_otlp_attributes([]) == {}

Expand Down
60 changes: 60 additions & 0 deletions tests/test_otlp_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,66 @@ def test_load_from_dict_empty_resource_spans(self):
assert traces == []


class TestAnyValueAttributes:
"""Attributes carrying the full OTLP AnyValue union (array / kvlist / bytes)."""

@staticmethod
def _load_span_with(attribute):
loader = OtlpJsonLoader()
data = {
"resourceSpans": [
{
"resource": {"attributes": []},
"scopeSpans": [
{
"scope": {"name": "test-scope"},
"spans": [
{
"traceId": "t1",
"spanId": "s1",
"name": "test",
"startTimeUnixNano": "1000000000",
"endTimeUnixNano": "2000000000",
"attributes": [attribute],
}
],
}
],
}
],
}
return loader.load_from_dict(data)[0].all_spans[0]

def test_array_value(self):
span = self._load_span_with(
{
"key": "gen_ai.response.finish_reasons",
"value": {"arrayValue": {"values": [{"stringValue": "stop"}]}},
}
)
assert span.tags["gen_ai.response.finish_reasons"] == ["stop"]

def test_kvlist_value(self):
span = self._load_span_with(
{
"key": "gen_ai.request.params",
"value": {
"kvlistValue": {
"values": [
{"key": "temperature", "value": {"doubleValue": 0.7}},
{"key": "stream", "value": {"boolValue": False}},
]
}
},
}
)
assert span.tags["gen_ai.request.params"] == {"temperature": 0.7, "stream": False}

def test_bytes_value(self):
span = self._load_span_with({"key": "payload", "value": {"bytesValue": "AP9oaQ=="}})
assert span.tags["payload"] == "AP9oaQ=="


class TestFlatDictAttributes:
"""Tests for flat dict attribute format (e.g. from simplified producers)."""

Expand Down