Skip to content

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

Merged
krisztianfekete merged 5 commits into
agentevals-dev:mainfrom
Leroyyyyyyyyy:fix/shared-anyvalue-decoder
Aug 31, 2026
Merged

fix(otel): share one AnyValue decoder across all OTLP paths#187
krisztianfekete merged 5 commits into
agentevals-dev:mainfrom
Leroyyyyyyyyy:fix/shared-anyvalue-decoder

Conversation

@Leroyyyyyyyyy

Copy link
Copy Markdown
Contributor

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
Contributor 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 thread src/agentevals/loader/otlp.py Outdated
Comment on lines 177 to 180

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?

Strands stores gen_ai.input.messages in span events, and newer GenAI

semconv makes messages a complex array, so the stringValue-only promotion

in the OTLP loader silently dropped anything that was not a plain string.

Route it through the shared decoder like the other paths.
flatten_otlp_attributes used to guarantee a scalar or nothing. Now that it

delegates to the shared AnyValue decoder it can also return lists and dicts,

which are unhashable and raise TypeError where these values are used as dict

keys (_active_session_for_name in ws_server.py and otlp_processing.py).

Add a string-only accessor and use it for both fields, matching what

_extract_conversation_id already did; that function now shares the same

implementation. The decoded value stays available in resource_attrs.
@Leroyyyyyyyyy

Copy link
Copy Markdown
Contributor Author

Both addressed, thanks — the second one was a real regression I missed.

Event attribute promotion (loader/otlp.py): now goes through
is_any_value / decode_any_value like the other paths, so a complex
gen_ai.input.messages array survives instead of being dropped.

I checked what that widens downstream: the promoted values are consumed via
parse_json_attr, which already accepts str | dict | list and returns dicts
and lists as-is, so nothing there depended on the old string-only shape.

session_name / eval_set_id: added _extract_string_attribute and read
both through it, so a list or kvlist can no longer reach
_active_session_for_name. _extract_conversation_id now shares that
implementation since it was already doing exactly this — happy to revert that
part if you'd rather keep the diff tighter. The decoded value is still in
resource_attrs for anything that wants it.

I left service.name on the decoder since it isn't used as a key anywhere —
say the word if you'd like it narrowed too.

Six tests added; the three regression ones fail if I revert the two source
changes. Full suite green locally (763 passed, 6 skipped), ruff clean.

CI still hasn't run on this branch — no checks reported. Happy to address
anything it turns up once someone can approve the workflow.

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.

Comment thread src/agentevals/api/otlp_processing.py Outdated
return span


def _extract_string_attribute(attrs_list: list[dict], key: str) -> str | None:

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.

Right fix, but it's per field and the hazard is per context. Can we coerce once for attrs the spec types as strings instead of adding an accessor per call site as we find them?

Comment thread tests/test_extraction.py
},
]
)
assert result == {"gen_ai.response.finish_reasons": ["stop"]}

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.

Stops at the decoder. Nothing asserts finish_reasons == ["stop"] out of extract_extended_model_info_from_attrs, which is the symptom #173 calls out.

Comment thread tests/test_otlp_loader.py
"value": {"arrayValue": {"values": [{"stringValue": "stop"}]}},
}
)
assert span.tags["gen_ai.response.finish_reasons"] == ["stop"]

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.

Same, stops at span.tags

The per-field accessor only covered the two dict-key sites we had found;

flatten_otlp_attributes still handed containers to every other consumer via

span.tags, so the same unhashable hazard survived elsewhere.

Move the guard into decode_attributes, keyed by SPEC_STRING_ATTRS in

trace_attrs. Attributes the spec types as strings come back str-or-absent;

a container in one of those slots is dropped with a warning rather than

JSON-dumped, since dumping would put the literal blob back into user-visible

output. Attributes the spec types as arrays or structured values keep their

decoded containers, which is what agentevals-dev#173 fixes.

The registry is hand-maintained: opentelemetry-semantic-conventions exposes

constants and prose docstrings only, so attribute value types are not

machine-readable and the set cannot be derived from the package.

_extract_string_attribute is gone and _extract_conversation_id is back to its

original form. Tests now assert through extract_extended_model_info_from_attrs

and extract_user_text_from_attrs, the layers agentevals-dev#173's symptom actually names,

instead of stopping at the decoder and span.tags.
@Leroyyyyyyyyy

Copy link
Copy Markdown
Contributor Author

Reworked along the lines you suggested — the per-field accessor is gone.

Coercion moved to the decode boundary. decode_attributes now narrows
attributes listed in a new SPEC_STRING_ATTRS set in trace_attrs.py, so the
guarantee is tied to the spec rather than to whichever consumers we've found so
far. _extract_string_attribute is deleted and _extract_conversation_id is
back to its original form — this round is net negative in otlp_processing.py.

It's a split rather than a blanket coercion: attributes the spec types as
strings are narrowed, attributes it types as arrays or structured values keep
their decoded containers. gen_ai.response.finish_reasons,
gen_ai.input/output.messages, gen_ai.tool.definitions and
gen_ai.system_instructions are deliberately absent from the set — narrowing
those would undo #173.

The registry is hand-maintained, with a note in the code saying why. I
checked whether it could be derived: opentelemetry-semantic-conventions only
exports constants plus prose docstrings —

GEN_AI_RESPONSE_FINISH_REASONS: Final = "gen_ai.response.finish_reasons"
"""Array of reasons the model stopped generating tokens, ..."""

— so the value type isn't machine-readable. Happy to revisit if you know of a
generated source I missed.

One judgement call you didn't specify: when a string-typed attribute
arrives as a container, I drop it and log a warning rather than JSON-dumping
it. Dumping would put the literal blob back into user-visible output, which is
the thing #173 is about. If you'd rather keep the data and accept the blob,
it's a one-line change.

Behaviour change worth flagging: non-string values in string-typed slots no
longer reach resource_attrs either, since the narrowing happens before the
dict is built. The test that asserted the opposite is inverted.

Tests moved up a layer, both places you pointed at:

Two of the new tests fail if I revert this round's source changes; the
symptom-level ones stay green because the previous round's decoder already
satisfies them — they're regression guards for the path rather than proof of
this change.

768 passed, 6 skipped locally, ruff clean.

One thing I left alone: the nested-dict path in the loader
(_flatten_nested_dict, for ClickHouse-style JSON columns) doesn't go through
decode_attributes and can surface containers the same way. That predates this
PR, so I didn't touch it — want a separate issue?

Comment thread src/agentevals/trace_attrs.py Outdated
Comment on lines +99 to +104
# Attributes the spec types as arrays or structured values are deliberately
# absent - gen_ai.response.finish_reasons, gen_ai.input/output.messages,
# gen_ai.tool.definitions and gen_ai.system_instructions must keep their
# decoded containers, which is the whole point of #173. Numeric attributes are
# likewise absent; the decoder already returns them as int/float.
SPEC_STRING_ATTRS: frozenset[str] = frozenset(

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.

The set covers gen_ai.* and agentevals.* but no gcp.vertex.agent.*. ADK_INVOCATION_ID is a dict key, so an arrayValue still gives an error. Confirmed.

@Leroyyyyyyyyy

Copy link
Copy Markdown
Contributor Author

You're right, and it's worse than one entry: gcp.vertex.agent.invocation_id
reaches a dict key in eight places in incremental_processor.py
(llm_spans_by_invocation, token_totals, seen_tool_calls), and it also
escapes into self.current_invocation_id and comes back out at line 208.

I haven't pushed a fix for it. I think the list itself is the problem, and
since I've already sent you two rounds chasing entries in it, I'd rather get
the direction right with you before writing more.

What I got wrong. #173 asks for one shared decoder so the lost values
survive. In the first round I implemented that as full fidelity for every
attribute
, which nothing asked for. Everything we've been trading rounds over
since — the missed call site, per-field vs per-context, and now the missing
namespace — is fallout from that widening, not from #173.

Here's what the two paths actually did before this PR:

# extraction.py - containers dropped entirely
if "stringValue" ... elif "intValue" ... elif "doubleValue" ... elif "boolValue" ...
return result

# loader/otlp.py - containers JSON-dumped as raw proto
elif "arrayValue" in value_obj:
    result[key] = json.dumps(value_obj["arrayValue"])

So process.command_args and friends were already being dropped or blobbed.
Full fidelity didn't restore them deliberately; it swept them up along with the
attributes #173 is about.

What I'd like to do instead: invert the list to opt-in.

SPEC_CONTAINER_ATTRS = frozenset({
    OTEL_GENAI_RESPONSE_FINISH_REASONS,
    OTEL_GENAI_INPUT_MESSAGES,
    OTEL_GENAI_OUTPUT_MESSAGES,
    OTEL_GENAI_TOOL_DEFINITIONS,
    OTEL_GENAI_SYSTEM_INSTRUCTIONS,
})

Scalars (including bytesValue, which decodes to a hashable str) always decoded;
containers decoded only for those keys, dropped otherwise. SPEC_STRING_ATTRS
goes away entirely.

The invocation_id problem doesn't get fixed under this — it stops existing.
gcp.vertex.agent.invocation_id isn't in the set, so it can never be a list,
so there's nothing to remember. Same for session_name, eval_set_id, and
every attribute neither of us has thought of.

Costs, so you can weigh them:

  • It's the inverse of what you suggested. Missing an entry now means a value
    gets dropped rather than crashing ingestion, but it is still a hand-kept list.
  • The set is a judgement call I'd like yours on: should
    gen_ai.tool.call.arguments / .result be in it? Newer semconv may make them
    structured.
  • One real behaviour change: on the loader path, non-opt-in containers go from a
    raw-proto blob string to absent. Low risk since the blob was unparseable, but
    it is a change.

And a question I think is separate from #173: what should the shared
decoder do with an arbitrary container attribute — drop it, JSON-dump the
decoded value, or keep it? That's a design call with a blast radius across every
consumer, and I don't think a bugfix PR should be the place it gets settled.
Happy to open an issue for it and keep this PR to the attributes #173 names.

Which way do you want it?

@krisztianfekete

Copy link
Copy Markdown
Contributor

You're right, and it's worse than one entry: gcp.vertex.agent.invocation_id reaches a dict key in eight places in incremental_processor.py (llm_spans_by_invocation, token_totals, seen_tool_calls), and it also escapes into self.current_invocation_id and comes back out at line 208.

I haven't pushed a fix for it. I think the list itself is the problem, and since I've already sent you two rounds chasing entries in it, I'd rather get the direction right with you before writing more.

What I got wrong. #173 asks for one shared decoder so the lost values survive. In the first round I implemented that as full fidelity for every attribute, which nothing asked for. Everything we've been trading rounds over since — the missed call site, per-field vs per-context, and now the missing namespace — is fallout from that widening, not from #173.

Here's what the two paths actually did before this PR:

# extraction.py - containers dropped entirely
if "stringValue" ... elif "intValue" ... elif "doubleValue" ... elif "boolValue" ...
return result

# loader/otlp.py - containers JSON-dumped as raw proto
elif "arrayValue" in value_obj:
    result[key] = json.dumps(value_obj["arrayValue"])

So process.command_args and friends were already being dropped or blobbed. Full fidelity didn't restore them deliberately; it swept them up along with the attributes #173 is about.

What I'd like to do instead: invert the list to opt-in.

SPEC_CONTAINER_ATTRS = frozenset({
    OTEL_GENAI_RESPONSE_FINISH_REASONS,
    OTEL_GENAI_INPUT_MESSAGES,
    OTEL_GENAI_OUTPUT_MESSAGES,
    OTEL_GENAI_TOOL_DEFINITIONS,
    OTEL_GENAI_SYSTEM_INSTRUCTIONS,
})

Scalars (including bytesValue, which decodes to a hashable str) always decoded; containers decoded only for those keys, dropped otherwise. SPEC_STRING_ATTRS goes away entirely.

The invocation_id problem doesn't get fixed under this — it stops existing. gcp.vertex.agent.invocation_id isn't in the set, so it can never be a list, so there's nothing to remember. Same for session_name, eval_set_id, and every attribute neither of us has thought of.

Costs, so you can weigh them:

  • It's the inverse of what you suggested. Missing an entry now means a value
    gets dropped rather than crashing ingestion, but it is still a hand-kept list.
  • The set is a judgement call I'd like yours on: should
    gen_ai.tool.call.arguments / .result be in it? Newer semconv may make them
    structured.
  • One real behaviour change: on the loader path, non-opt-in containers go from a
    raw-proto blob string to absent. Low risk since the blob was unparseable, but
    it is a change.

And a question I think is separate from #173: what should the shared decoder do with an arbitrary container attribute — drop it, JSON-dump the decoded value, or keep it? That's a design call with a blast radius across every consumer, and I don't think a bugfix PR should be the place it gets settled. Happy to open an issue for it and keep this PR to the attributes #173 names.

Which way do you want it?

Thanks for checking, let's do the inversion! A missing denylist entry crashes ingestion on an unauthenticated port; a missing allowlist entry drops one value, which is what the extraction layer does already.

I checked your proposal and none reach a dict key or set, so the crash goes away structurally, that has been my intention.

Yes to both gen_ai.tool.call.arguments and .result. extraction.py reads arguments through parse_json_attr, it reads result through parse_tool_response_content which handles str/dict/other. Neither is a key, and without them structured tool args will get dropped.

Yes to separate issues for the arbitrary container question and _flatten_nested_dict.

One thing the inversion won't fix though, is that model in the extraction logic comes from the ADK llm_request blob, not an attribute, so nothing attribute-level reaches models_used.add() in ws_server.py. That's more like a task for #169

…list

SPEC_STRING_ATTRS listed the attributes to narrow, so a missing entry let an

unhashable value reach a dict key on an unauthenticated receiver port. It

missed the whole gcp.vertex.agent.* namespace.

Replace it with SPEC_CONTAINER_ATTRS: the seven keys the GenAI semconv types as

arrays or structured values. Those decode to native containers; every other key

decodes to a scalar or is dropped, which is what extraction.py did before the

decoder was shared. A missing entry now drops one value rather than crashing

ingestion, and attributes neither of us has thought about are safe by

construction rather than by enumeration.

decode_attribute() carries the rule so the OTLP-array path and the span-event

promotion path share one implementation. bytesValue now survives as the base64

string it already was, which agentevals-dev#173 asks for and is hashable.

The default for unlisted container values is tracked in agentevals-dev#208; the nested-dict

loader path that bypasses this decoder entirely is agentevals-dev#207.
@Leroyyyyyyyyy

Copy link
Copy Markdown
Contributor Author

Inverted, and SPEC_STRING_ATTRS is gone. The set is the seven you signed off
on — the five I proposed plus gen_ai.tool.call.arguments and .result.

I checked the seven independently rather than take it on trust: every reference
to them across src/ is a .get() or get_tag() read feeding
parse_json_attr, _parse_finish_reasons or parse_tool_response_content.
None is a dict key or set member.

One detail I'd add: models_used.add() is fed from an attribute — the
else branch takes gen_ai.request.model via span.get_tag() and puts it in a
set:

genai_model = span.get_tag(OTEL_GENAI_REQUEST_MODEL)
if genai_model:
    models_used.add(genai_model)

It's safe, because gen_ai.request.model isn't in the allowlist and therefore
can never be a list. But it's worth naming: that's a third attribute-fed
key/set position, after session_name and invocation_id, and my own consumer
sweep last round missed it too — I grepped for [var] and .get(var) and not
for .add(var). Three attempts at enumerating the hazard, three misses. The
allowlist covers it without anyone having to notice it, which is the argument
for it better than anything I wrote earlier.

Two things to flag:

  • bytesValue now survives as the base64 string it already was. Pre-PR
    extraction.py had no branch for it at all, so this is a behaviour change —
    intended, since [OTel] Three different AnyValue decoders, two of them lossy #173 names bytes among the dropped types, and hashable so it
    carries no risk.
  • gen_ai.system_instructions and gen_ai.tool.definitions currently have no
    consumers anywhere in src/. I kept them because the spec types them as
    containers, but if you'd rather the set only cover what we actually read, say
    so and I'll trim it to five.

Issues filed as agreed: #208 for the default behaviour on unlisted
container attributes, #207 for _flatten_nested_dict bypassing the decoder
entirely. Both are referenced from the code comments. Happy to take #207.

CI still hasn't run here, so I reproduced the workflow locally, including
the bottom of the version matrix:

uv lock --check                                    Resolved 170 packages
uv run ruff check .                                All checks passed
uv run ruff format --check .                       151 files already formatted
pytest -m "not integration and not e2e"
  Python 3.14   769 passed, 6 skipped, 47 deselected
  Python 3.11   769 passed, 6 skipped, 47 deselected

make helm-test and make build-ui I didn't run — this round only touches the
Python decode layer, nothing in the chart or the UI.

@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.

Thank you!

@krisztianfekete
krisztianfekete merged commit 46ec211 into agentevals-dev:main Aug 31, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants