Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
65a71ae
feat: add schema URL handling and alias-aware attribute resolution
AaronProbha18 Jul 7, 2026
d5a30ba
fix: add UTF-8 encoding to file opening in loaders and runner
AaronProbha18 Jul 7, 2026
c6a92ba
feat: add semconv_version to TraceConversionMetadata and related meta…
AaronProbha18 Jul 7, 2026
5256b44
revert: undo utf-8 changes
AaronProbha18 Jul 7, 2026
8201aef
fix(extraction): correctly parse semconv version from schema_url & ch…
AaronProbha18 Jul 8, 2026
98e5351
test: update resolve_semconv_version tests for improved handling of e…
AaronProbha18 Jul 8, 2026
3db9eab
fix: fall back to resource-level schemaUrl when scope omits it
AaronProbha18 Jul 8, 2026
81bd145
refactor: rename semconv_version to schema_version
AaronProbha18 Jul 8, 2026
04ee8ae
docs: add note about semconv version being derived from first LLM span
AaronProbha18 Jul 8, 2026
394e6cd
test: select LLM span by predicate instead of position
AaronProbha18 Jul 8, 2026
41ddbbf
test: cover OTLP scopeSpans.schemaUrl to schemaVersion conversion
AaronProbha18 Jul 8, 2026
a4fd95f
refactor: GENAI_ATTRIBUTE_ALIASES default to None
AaronProbha18 Jul 8, 2026
c834125
restore: GENAI_ATTRIBUTE_ALIASES dict
AaronProbha18 Jul 8, 2026
9a938ff
refactor: default schema_version to None
AaronProbha18 Jul 8, 2026
1e3212e
style: apply Ruff lint fixes and formatting
AaronProbha18 Jul 8, 2026
53c6b3d
Merge branch 'agentevals-dev:main' into feat/otel-genai-attr-aliases
AaronProbha18 Jul 8, 2026
861c7f9
fix: update schema_version assertion to None
AaronProbha18 Jul 8, 2026
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
18 changes: 18 additions & 0 deletions src/agentevals/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ class TraceConversionMetadata(CamelModel):
model: str | None = None
response_model: str | None = None
provider: str | None = None
schema_version: str | None = Field(
default=None,
description=(
"Version segment parsed from the emitting scope's schema URL "
"(the path component after the schema family prefix). Per the "
"OpenTelemetry schema URL spec, this version is only meaningful "
"within its schema family — the host/path prefix before the "
"version. It is comparable across traces only if they share a "
"family, and equals the OpenTelemetry Semantic Conventions "
"version only for the OTel family "
"(https://opentelemetry.io/schemas/<version>). For traces from "
"a custom or mirrored schema family, this is that family's own "
"version number, not an OTel semconv version. The full schema "
"URL (otel.schema_url) is preserved separately wherever the "
"family needs to be disambiguated; this field is informational "
"only and does not drive extraction or alias-resolution logic."
),
)
start_time: int | None = None
user_input_preview: str | None = None
final_output_preview: str | None = None
Expand Down
15 changes: 11 additions & 4 deletions src/agentevals/api/otlp_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
OTEL_GENAI_CONVERSATION_ID,
OTEL_GENAI_INPUT_MESSAGES,
OTEL_GENAI_OUTPUT_MESSAGES,
OTEL_SCHEMA_URL,
OTEL_SCOPE,
OTEL_SCOPE_VERSION,
)
Expand All @@ -38,6 +39,7 @@ async def process_traces(body: dict, manager: StreamingTraceManager) -> None:
for resource_span in body.get("resourceSpans", []):
resource_attrs = resource_span.get("resource", {}).get("attributes", [])
metadata = _extract_agentevals_metadata(resource_attrs)
resource_schema_url = resource_span.get("schemaUrl", "")

if not metadata.get("conversation_id"):
metadata["conversation_id"] = _prescan_conversation_id(resource_span)
Expand All @@ -46,9 +48,10 @@ async def process_traces(body: dict, manager: StreamingTraceManager) -> None:
scope = scope_span.get("scope", {})
scope_name = scope.get("name", "")
scope_version = scope.get("version", "")
schema_url = scope_span.get("schemaUrl", "") or resource_schema_url

for span_data in scope_span.get("spans", []):
span = _normalize_span(span_data, scope_name, scope_version)
span = _normalize_span(span_data, scope_name, scope_version, schema_url)
trace_id = span.get("traceId", "")

if not trace_id:
Expand Down Expand Up @@ -200,12 +203,13 @@ def fix_protobuf_id_fields(data) -> None:
_GENAI_EVENT_KEYS = {OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_OUTPUT_MESSAGES}


def _normalize_span(span_data: dict, scope_name: str, scope_version: str) -> dict:
def _normalize_span(span_data: dict, scope_name: str, scope_version: str, schema_url: str = "") -> dict:
"""Normalize an OTLP span for the downstream pipeline.

Performs two transformations:
1. Injects otel.scope.name/version from the scopeSpans level into span
attributes (the pipeline expects them there).
1. Injects otel.scope.name/version (and otel.schema_url) from the
scopeSpans level into span attributes (the pipeline expects them
there).
2. Promotes gen_ai.input.messages and gen_ai.output.messages from span
events to span attributes. Some SDKs (e.g. Strands with
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental) store
Expand All @@ -222,6 +226,9 @@ def _normalize_span(span_data: dict, scope_name: str, scope_version: str) -> dic
if scope_version and OTEL_SCOPE_VERSION not in existing_keys:
attrs.append({"key": OTEL_SCOPE_VERSION, "value": {"stringValue": scope_version}})
existing_keys.add(OTEL_SCOPE_VERSION)
if schema_url and OTEL_SCHEMA_URL not in existing_keys:
attrs.append({"key": OTEL_SCHEMA_URL, "value": {"stringValue": schema_url}})
existing_keys.add(OTEL_SCHEMA_URL)

for event in span.get("events", []):
for attr in event.get("attributes", []):
Expand Down
1 change: 1 addition & 0 deletions src/agentevals/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ async def convert_trace_files(
meta = TraceConversionMetadata(
agent_name=meta_dict.get("agent_name"),
model=meta_dict.get("model"),
schema_version=meta_dict.get("schema_version"),
start_time=meta_dict.get("start_time"),
user_input_preview=meta_dict.get("user_input_preview"),
final_output_preview=meta_dict.get("final_output_preview"),
Expand Down
75 changes: 72 additions & 3 deletions src/agentevals/extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import json
import logging
import re
from typing import Any, Protocol, TypedDict, TypeVar

from .loader.base import Span, Trace
Expand All @@ -22,6 +23,7 @@
ADK_SCOPE_VALUE,
ADK_TOOL_CALL_ARGS,
ADK_TOOL_RESPONSE,
GENAI_ATTRIBUTE_ALIASES,
OTEL_ERROR_TYPE,
OTEL_GENAI_INPUT_MESSAGES,
OTEL_GENAI_OP,
Expand All @@ -44,6 +46,7 @@
OTEL_GENAI_USAGE_CACHE_READ_TOKENS,
OTEL_GENAI_USAGE_INPUT_TOKENS,
OTEL_GENAI_USAGE_OUTPUT_TOKENS,
OTEL_SCHEMA_URL,
OTEL_SCOPE,
)
from .utils.genai_messages import (
Expand All @@ -58,6 +61,61 @@

FORMAT_DETECTION_SPAN_LIMIT = 10

# ---------------------------------------------------------------------------
# Alias-aware attribute resolution
# ---------------------------------------------------------------------------

# Per the OTel schema URL spec (https://opentelemetry.io/docs/specs/otel/schemas/#schema-url),
# a schema URL has the form `http[s]://server[:port]/path/<version>`.
_SCHEMA_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")


def resolve_attr(attrs: dict[str, Any], canonical_key: str) -> Any | None:
"""Look up *canonical_key* in *attrs*, falling back to older aliased names.

Resolution order:
1. The canonical (current/newest) key, if present with a truthy value.
2. Each alias in ``GENAI_ATTRIBUTE_ALIASES[canonical_key]``, in order,
first truthy value wins.
3. ``None`` if neither the canonical key nor any alias is present.

The canonical value always takes precedence over aliases, even if both
are present on the same span. This resolves only against the flat attrs
dict at read time - it never mutates or rewrites stored/transmitted
attributes.
"""
value = attrs.get(canonical_key)
if value:
return value

for alias in GENAI_ATTRIBUTE_ALIASES.get(canonical_key, []):
alias_value = attrs.get(alias)
if alias_value:
return alias_value

return None


def resolve_schema_version(schema_url: str | None) -> str | None:
"""Extract the version segment from a schema URL.

Per the OTel schema URL spec, the version is the last path segment
(e.g. "1.37.0" in "https://opentelemetry.io/schemas/1.37.0").

It's scoped to that URL's schema family and only equals the OTel semconv
version for the official OTel family. Degrades gracefully to None for
missing, empty, or malformed input - never raises.
"""
if not schema_url or not isinstance(schema_url, str):
return None

last_segment = schema_url.rstrip("/").rsplit("/", 1)[-1]
if _SCHEMA_VERSION_RE.match(last_segment):
return last_segment

return None


# ---------------------------------------------------------------------------
# Pure extraction functions (operate on flat attribute dicts)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -189,25 +247,36 @@ class ExtendedModelInfo(TypedDict):
cache_creation_tokens: int
cache_read_tokens: int
error_type: str | None
schema_version: str | None


def extract_extended_model_info_from_attrs(attrs: dict[str, Any]) -> ExtendedModelInfo:
"""Extract extended model and provider metadata from span attributes.

Uses gen_ai.system as fallback for provider when gen_ai.provider.name is
absent (backward compat with pre-v1.37.0 instrumentors).
Uses the alias-resolving lookup for attributes with known historical
renames (e.g. provider falls back to gen_ai.system when
gen_ai.provider.name - the canonical, current name - is absent, for
backward compat with pre-v1.37.0 instrumentors).

``schema_version`` is resolved from the scope's ``schema_url`` (captured
at ingest as the ``otel.schema_url`` attribute) and is ``None`` when
absent or malformed. This is purely informational metadata about which
schema version emitted the span - it is never used to detect whether a
span is GenAI (that remains the sole responsibility of the existing
gen_ai.* presence checks).
"""
return {
"request_model": attrs.get(OTEL_GENAI_REQUEST_MODEL),
"response_model": attrs.get(OTEL_GENAI_RESPONSE_MODEL),
"provider": attrs.get(OTEL_GENAI_PROVIDER_NAME) or attrs.get(OTEL_GENAI_SYSTEM),
"provider": resolve_attr(attrs, OTEL_GENAI_PROVIDER_NAME),
"finish_reasons": _parse_finish_reasons(attrs.get(OTEL_GENAI_RESPONSE_FINISH_REASONS)),
"response_id": attrs.get(OTEL_GENAI_RESPONSE_ID),
"temperature": _safe_cast(attrs.get(OTEL_GENAI_REQUEST_TEMPERATURE), float),
"max_tokens": _safe_cast(attrs.get(OTEL_GENAI_REQUEST_MAX_TOKENS), int),
"cache_creation_tokens": _safe_cast(attrs.get(OTEL_GENAI_USAGE_CACHE_CREATION_TOKENS), int, 0),
"cache_read_tokens": _safe_cast(attrs.get(OTEL_GENAI_USAGE_CACHE_READ_TOKENS), int, 0),
"error_type": attrs.get(OTEL_ERROR_TYPE),
"schema_version": resolve_schema_version(attrs.get(OTEL_SCHEMA_URL)),
}


Expand Down
10 changes: 8 additions & 2 deletions src/agentevals/loader/otlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ..trace_attrs import (
OTEL_GENAI_INPUT_MESSAGES,
OTEL_GENAI_OUTPUT_MESSAGES,
OTEL_SCHEMA_URL,
OTEL_SCOPE,
OTEL_SCOPE_VERSION,
)
Expand Down Expand Up @@ -93,21 +94,23 @@ def _parse_otlp_export(self, data: dict) -> list[Trace]:
all_spans = []
for resource_span in resource_spans:
resource_attrs = self._extract_attributes(resource_span.get("resource", {}).get("attributes", []))
resource_schema_url = resource_span.get("schemaUrl", "")
scope_spans = resource_span.get("scopeSpans") or resource_span.get("instrumentationLibrarySpans", [])
for scope_span in scope_spans:
scope = scope_span.get("scope") or scope_span.get("instrumentationLibrary") or {}
scope_name = scope.get("name", "")
scope_version = scope.get("version", "")
schema_url = scope_span.get("schemaUrl", "") or resource_schema_url

for span_data in scope_span.get("spans", []):
span = self._parse_span(span_data, resource_attrs, scope_name, scope_version)
span = self._parse_span(span_data, resource_attrs, scope_name, scope_version, schema_url)
all_spans.append(span)

return self._build_traces(all_spans)

def _parse_otlp_spans(self, spans_data: list[dict]) -> list[Trace]:
"""Parse flat list of OTLP spans (JSONL format for streaming)."""
all_spans = [self._parse_span(span_data, {}, "", "") for span_data in spans_data]
all_spans = [self._parse_span(span_data, {}, "", "", "") for span_data in spans_data]
return self._build_traces(all_spans)

_GENAI_EVENT_KEYS = {OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_OUTPUT_MESSAGES}
Expand All @@ -118,6 +121,7 @@ def _parse_span(
resource_attrs: dict,
scope_name: str,
scope_version: str,
schema_url: str = "",
) -> Span:
"""Convert OTLP span to normalized Span object."""
attributes = self._extract_attributes(span_data.get("attributes", []))
Expand All @@ -126,6 +130,8 @@ def _parse_span(
attributes[OTEL_SCOPE] = scope_name
if scope_version:
attributes[OTEL_SCOPE_VERSION] = scope_version
if schema_url:
attributes[OTEL_SCHEMA_URL] = schema_url

self._promote_genai_event_attributes(span_data, attributes)

Expand Down
3 changes: 3 additions & 0 deletions src/agentevals/streaming/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,15 @@ def force_flush(self, timeout_millis: int = 30000) -> bool:
def _span_to_otlp(self, span: ReadableSpan) -> dict:
scope_name = span.instrumentation_scope.name if span.instrumentation_scope else ""
scope_version = span.instrumentation_scope.version if span.instrumentation_scope else ""
schema_url = span.instrumentation_scope.schema_url if span.instrumentation_scope else ""

attributes = []
if scope_name:
attributes.append({"key": "otel.scope.name", "value": {"stringValue": scope_name}})
if scope_version:
attributes.append({"key": "otel.scope.version", "value": {"stringValue": scope_version}})
if schema_url:
attributes.append({"key": "otel.schema_url", "value": {"stringValue": schema_url}})

if span.attributes:
for key, value in span.attributes.items():
Expand Down
8 changes: 8 additions & 0 deletions src/agentevals/trace_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
Covers OTel GenAI semantic conventions up to v1.40.0.
"""

from typing import Optional

# OTel resource
OTEL_SERVICE_NAME = "service.name"

# OTel scope
OTEL_SCOPE = "otel.scope.name"
OTEL_SCOPE_VERSION = "otel.scope.version"
OTEL_SCHEMA_URL = "otel.schema_url"

# Google ADK scope value
ADK_SCOPE_VALUE = "gcp.vertex.agent"
Expand Down Expand Up @@ -73,3 +76,8 @@
ADK_TOOL_CALL_ARGS = "gcp.vertex.agent.tool_call_args"
ADK_TOOL_RESPONSE = "gcp.vertex.agent.tool_response"
ADK_INVOCATION_ID = "gcp.vertex.agent.invocation_id"

# GenAI attribute alias table
GENAI_ATTRIBUTE_ALIASES: dict[str, list[str]] = {
OTEL_GENAI_PROVIDER_NAME: [OTEL_GENAI_SYSTEM],
}
7 changes: 7 additions & 0 deletions src/agentevals/trace_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def extract_trace_metadata(trace, extractor=None) -> dict[str, Any]:
"model": None,
"response_model": None,
"provider": None,
"schema_version": None,
"start_time": None,
"user_input_preview": None,
"final_output_preview": None,
Expand All @@ -204,11 +205,17 @@ def extract_trace_metadata(trace, extractor=None) -> dict[str, Any]:
if llm_spans:
metadata["model"] = llm_spans[0].get_tag(OTEL_GENAI_REQUEST_MODEL)

# NOTE: `ext` is extracted only from `llm_spans[0]` (the first LLM
# span). If a trace mixes instrumentors or different
# instrumentation versions, extended model info that appears only
# on later LLM spans may not be captured here.
ext = extract_extended_model_info_from_attrs(llm_spans[0].tags)
if ext["response_model"]:
metadata["response_model"] = ext["response_model"]
if ext["provider"]:
metadata["provider"] = ext["provider"]
if ext["schema_version"]:
metadata["schema_version"] = ext["schema_version"]

user_text = extract_user_text_from_attrs(llm_spans[0].tags)
if user_text:
Expand Down
58 changes: 58 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,64 @@ def test_tempo_json_upload_auto_detected(self):
assert traces[0]["traceId"] == "dd547580319ab0312cee07f1def50dad"
assert len(traces[0]["invocations"]) == 1

def test_convert_otlp_scope_schema_url_propagates_schema_version(self):
payload = _make_otlp_json_payload()
scope_spans = payload["resourceSpans"][0]["scopeSpans"][0]
scope_spans["schemaUrl"] = "https://opentelemetry.io/schemas/1.39.0"
scope_spans["spans"].append(
{
"traceId": scope_spans["spans"][0]["traceId"],
"spanId": "s2",
"parentSpanId": scope_spans["spans"][0]["spanId"],
"name": "call_llm",
"startTimeUnixNano": "1100000",
"endTimeUnixNano": "1500000",
"attributes": [
{
"key": "gcp.vertex.agent.llm_request",
"value": {
"stringValue": json.dumps({"contents": [{"role": "user", "parts": [{"text": "hello"}]}]})
},
},
{
"key": "gcp.vertex.agent.llm_response",
"value": {"stringValue": json.dumps({"content": {"role": "model", "parts": [{"text": "hi"}]}})},
},
],
}
)

body = _assert_envelope(
self.client.post(
"/api/convert",
files={"trace_files": ("trace.json", io.BytesIO(json.dumps(payload).encode()))},
)
)

metadata = body["data"]["traces"][0]["metadata"]
assert metadata["schemaVersion"] == "1.39.0"

@patch("agentevals.api.routes.extract_trace_metadata")
Comment thread
krisztianfekete marked this conversation as resolved.
def test_convert_metadata_includes_schema_version(self, mock_extract_trace_metadata):
mock_extract_trace_metadata.return_value = {
"agent_name": "agent",
"model": "gpt-4o",
"schema_version": "1.39.0",
"start_time": 123,
"user_input_preview": "hello",
"final_output_preview": "world",
}

body = _assert_envelope(
self.client.post(
"/api/convert",
files={"trace_files": ("trace.json", io.BytesIO(self._tempo_fixture_bytes()))},
)
)

metadata = body["data"]["traces"][0]["metadata"]
assert metadata["schemaVersion"] == "1.39.0"

def test_unknown_shape_returns_load_warning(self):
unknown = json.dumps({"some_other_shape": []}).encode()
resp = self.client.post(
Expand Down
Loading