Description
Split out of the #187 review, as agreed there.
OtlpJsonLoader._extract_attributes has two branches, and only one goes through the shared AnyValue decoder:
def _extract_attributes(self, attrs) -> dict:
if isinstance(attrs, dict):
return self._flatten_nested_dict(attrs) # no decoding, no allowlist
return decode_attributes(attrs) # decoder + SPEC_CONTAINER_ATTRS
The dict branch handles the flat and nested (ClickHouse JSON column) formats.
It recurses into dicts and passes everything else through untouched:
for key, value in d.items():
full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, dict):
result.update(OtlpJsonLoader._flatten_nested_dict(value, full_key))
else:
result[full_key] = value # a list lands in span.tags as a list
So the guarantee #187 establishes on the OTLP-array path — containers only for allowlisted keys — does not hold here. A list reaches span.tags, and gcp.vertex.agent.invocation_id is then used as a dict key in eight places in incremental_processor.py (llm_spans_by_invocation, token_totals, seen_tool_calls).
This predates #187 — the dict branch never had any narrowing — so it is not a regression from that PR, but it is the same hazard on a path the PR does not cover.
Expected behavior
Both branches of _extract_attributes should end up with the same contract: container values only for keys on the allowlist, narrowed otherwise. A nested-format trace should not be able to put an unhashable value where the OTLP-array format cannot.
Steps to reproduce
Load a nested-format (ClickHouse JSON column) trace whose attributes carry a list where a scalar is expected:
{"gcp": {"vertex": {"agent": {"invocation_id": ["a", "b"]}}}}
_flatten_nested_dict flattens it to {"gcp.vertex.agent.invocation_id": ["a", "b"]}, which reaches self.seen_tool_calls[invocation_id] in incremental_processor.py and raises TypeError: unhashable type: 'list'.
How are you using agentevals?
CLI file evaluation (agentevals run)
Config dump (ZIP from the web UI)
No response
Version information
agentevals version: 0.9.9.dev10+gc65f82d19.d20260828
Python version: 3.14.6
OS: macOS 26.4 (arm64)
Eval config (if applicable)
Trace and eval set files
No response
Relevant logs or error output
Additional context
Likely fix: apply the same allowlist rule in _flatten_nested_dict's leaf case, via a shared helper rather than a second copy of the rule.
I can take this one if it's useful — same area as #187.
Human confirmation
Description
Split out of the #187 review, as agreed there.
OtlpJsonLoader._extract_attributeshas two branches, and only one goes through the sharedAnyValuedecoder:The dict branch handles the flat and nested (ClickHouse JSON column) formats.
It recurses into dicts and passes everything else through untouched:
So the guarantee #187 establishes on the OTLP-array path — containers only for allowlisted keys — does not hold here. A list reaches
span.tags, andgcp.vertex.agent.invocation_idis then used as a dict key in eight places inincremental_processor.py(llm_spans_by_invocation,token_totals,seen_tool_calls).This predates #187 — the dict branch never had any narrowing — so it is not a regression from that PR, but it is the same hazard on a path the PR does not cover.
Expected behavior
Both branches of _extract_attributes should end up with the same contract: container values only for keys on the allowlist, narrowed otherwise. A nested-format trace should not be able to put an unhashable value where the OTLP-array format cannot.
Steps to reproduce
Load a nested-format (ClickHouse JSON column) trace whose attributes carry a list where a scalar is expected:
{"gcp": {"vertex": {"agent": {"invocation_id": ["a", "b"]}}}}_flatten_nested_dictflattens it to{"gcp.vertex.agent.invocation_id": ["a", "b"]}, which reachesself.seen_tool_calls[invocation_id]inincremental_processor.pyand raisesTypeError: unhashable type: 'list'.How are you using agentevals?
CLI file evaluation (
agentevals run)Config dump (ZIP from the web UI)
No response
Version information
Eval config (if applicable)
Trace and eval set files
No response
Relevant logs or error output
Additional context
Likely fix: apply the same allowlist rule in _flatten_nested_dict's leaf case, via a shared helper rather than a second copy of the rule.
I can take this one if it's useful — same area as #187.
Human confirmation