Skip to content

fix(otel): share one AnyValue decoder across all OTLP paths - #187

Open
Leroyyyyyyyyy wants to merge 1 commit into
agentevals-dev:mainfrom
Leroyyyyyyyyy:fix/shared-anyvalue-decoder
Open

fix(otel): share one AnyValue decoder across all OTLP paths#187
Leroyyyyyyyyy wants to merge 1 commit into
agentevals-dev:mainfrom
Leroyyyyyyyyy:fix/shared-anyvalue-decoder

Conversation

@Leroyyyyyyyyy

Copy link
Copy Markdown

Fixes #173

Problem

Three places decoded the OTLP AnyValue union, and only one did it fully:

Call site arrayValue / kvlistValue bytesValue
extraction.flatten_otlp_attributes attribute dropped entirely dropped
loader.otlp.OtlpJsonLoader._extract_attributes json.dumps of the raw proto wrapper dropped
api.otlp_processing._parse_otlp_any_value decoded recursively ✅ returned as-is

Feeding one span attribute — gen_ai.response.finish_reasons carrying arrayValue: ["stop"] — through the receiver paths produced three different answers before this change:

Path Before After
HTTP protobuf → OtlpJsonLoader '{"values": [{"stringValue": "stop"}]}' ["stop"]
OTLP/JSON → OtlpJsonLoader '{"values": [{"stringValue": "stop"}]}' ["stop"]
extraction / streaming attribute missing entirely ["stop"]
log body ["stop"] ["stop"]

Note that even after a json.loads, the loader's value is still the proto wrapper — not the decoded list.

Approach

Moved the already-correct recursive decoder into a new agentevals/otlp_anyvalue.py and pointed all three call sites at it. The gRPC receiver needs no change: it hands MessageToDict output to the same process_traces.

The new module imports only the standard library. That is deliberate: extraction imports loader.base, which eagerly initialises the loader package (and therefore loader.otlp), so having loader.otlp import from extraction would create a real import cycle. A leaf module has no edge back into the package and cannot participate in one.

Two behaviours are intentionally preserved:

  • bytesValue is returned unchanged. MessageToDict base64-encodes protobuf bytes fields and OTLP/JSON does the same, so call sites already receive a str. Decoding to real bytes would be a behaviour change beyond this fix.
  • Attributes carrying none of the seven union fields are still skipped rather than becoming {}is_any_value() keeps the prior semantics of both flatteners.

The loader's dict-shaped attribute branch (_flatten_nested_dict, for ClickHouse-style nested JSON) is untouched; only the OTLP array branch now shares the decoder.

Testing

array / kvlist / bytes attributes had no test coverage on either path — which is how the mismatch survived. Added 7 tests across tests/test_extraction.py and tests/test_otlp_loader.py, including the nested arrayValue-of-kvlistValue shape used for tool calls.
758 passed, 6 skipped (unit suite)
ruff check . / ruff format --check . clean

Three AnyValue decoders had drifted apart. extraction's
flatten_otlp_attributes silently dropped array/kvlist/bytes attributes,
and the OTLP JSON loader json.dumps()'d the raw proto wrapper instead of
decoding it. gen_ai.response.finish_reasons therefore surfaced as
'{"values": [{"stringValue": "stop"}]}' on one path and vanished on
another, where both should yield ["stop"].

Move the already-correct recursive decoder out of api/otlp_processing.py
into a dependency-free module and route all three call sites through it.
The new module imports only the standard library, so extraction,
loader.otlp and api.otlp_processing can all share it without creating an
import cycle.

bytesValue is returned unchanged: MessageToDict base64-encodes protobuf
bytes fields, so callers already receive a str today. Decoding it here
would change existing behaviour, which is out of scope for this fix.

Adds coverage for array/kvlist/bytes attributes, which previously had
none on either path.

Fixes agentevals-dev#173
@Leroyyyyyyyyy
Leroyyyyyyyyy force-pushed the fix/shared-anyvalue-decoder branch from e007c14 to c65f82d Compare August 27, 2026 12:56
@Leroyyyyyyyyy

Copy link
Copy Markdown
Author

Rebased onto latest main. CI hasn't run on this PR — looks like it's waiting on the first-time-contributor workflow approval. Could a maintainer kick it off? Happy to address anything it turns up.

@krisztianfekete krisztianfekete left a comment

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.

Thanks, added two comments, can you please take a look at them?

Comment on lines 177 to 180
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"]

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.

Here we still hand roll stringValue only handling. Strands puts gen_ai.input.messages in span events, and newer GenAI semconv makes messages a complex array, so that promotion still silently drops anything that isn't a plain string. Can you please fix this as well?


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

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.

flatten_otlp_attributes used to guarantee scalar or absent. After this change can return a list or dict, and _extract_agentevals_metadatafeeds agentevals.session_name straight into self._active_session_for_name.get(session_name) in ws_server.py and otlp_processing.py for logs.

Can we read session_name and eval_set_id with a string only accessor, the way _extract_conversation_id already does in otlp_processing.py?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OTel] Three different AnyValue decoders, two of them lossy

2 participants