You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fromstrands_evals.mappers.session_mapperimportSessionMapperm=SessionMapper.__new__(SessionMapper) # any concrete mapperm.parse_timestamp("2024-01-01T00:00:00").tzinfo# -> None (naive)m.parse_timestamp("2024-01-01T00:00:00Z").tzinfo# -> UTC (aware)m.parse_timestamp(None).tzinfo# -> UTC (aware)
Expected Behavior
Per the docstring (mappers/session_mapper.py:91): "Returns: Timezone-aware datetime in UTC" — for every input shape.
Actual Behavior
Two of the branches violate the contract (session_mapper.py:95-96 passes a naive datetime through unchanged; :103datetime.fromisoformat returns naive for offset-less ISO strings). A single mapped session can therefore mix naive and aware start_times — e.g. one span missing start_time (aware now()) alongside offset-less ISO stamps (naive). parse_timestamp is used by the openinference (:602), langchain (:454), and adk (:569) mappers.
Any consumer that compares or sorts span_info.start_time across spans then raises TypeError: can't compare offset-naive and offset-aware datetimes (verified; e.g. the sort in #343's proposed TraceIndex). Existing code defends against this individually — extractors/trace_extractor.py:24-28 has _to_aware_utc — but the mapper contract itself is broken.
Possible Solution
Normalize in parse_timestamp itself: apply the _to_aware_utc treatment (assume UTC for naive input) to the datetime-passthrough and fromisoformat branches, making the docstring true and letting downstream defenses retire.
@liramon2 The fix is implemented, tested, and independently reviewed (two fresh-context review rounds, both APPROVE) — but I can't open the PR myself: pushing to this repo returns 403, I have no fork of evals, and fork creation is blocked by my guardrails (same access gap as in #355, still unresolved — no collaborator invite pending). So the apply-ready patch is below instead.
To apply: save the collapsed patch as fix-372.patch and git am fix-372.patch. Or grant strandly-the-agent a fork/collaborator path and I'll open the PR properly.
What it does: adds a _to_utc helper next to _to_aware_utc in types/trace.py (naive → assume UTC, aware → convert to UTC, with an OverflowError guard for datetime.min/max across non-UTC offsets) and uses it in parse_timestamp's datetime-passthrough and fromisoformat branches — every branch now honors the "timezone-aware UTC" docstring, so mapped sessions can no longer mix naive and aware start_times.
Verification (all on the patch commit, Python 3.13)
Full unit suite (pytest tests/, all extras installed): 2130 passed, 0 failed
ruff check src/ tests/ + ruff format --check on touched files: clean
✅ astimezone could raise OverflowError for boundary instants with non-UTC offsets (a regression vs main, where such strings parsed) → guarded in _to_utc
✅ _to_aware_utc docstring overstated its behavior ("normalize to UTC" — it only attaches UTC to naive input) → corrected
✅ No test pinned the reported symptom → naive-datetime span added to the openinference mixed-start_time test
⏸️ Deferred: move TestTimestampParsing to a new test_session_mapper.py (base-class behavior is tested in the ADK file). Kept in place to match the existing convention and keep the diff minimal; a follow-up can relocate it per AGENTS.md's mirror-the-src rule.
Round 2 — APPROVE, 1 🟡 + 4 ⚪; 🟡 and one ⚪ taken, rest declined as out of scope for a minimal bugfix:
✅ 🟡 The overflow guard had zero test coverage (proven by mutation) → added TestToUtc unit tests + boundary ISO cases
✅ ⚪ Returns: docstring was false on the boundary path → documented the exception
❌ Declined: rename _to_aware_utc→_ensure_aware, dedupe with serialize_datetime_utc, rename test_datetime_passthrough — all widen the diff without fixing behavior
📌 Open question surfaced for maintainers: for boundary instants the guard keeps the original offset, and serialize_datetime_utc still raises on them at model_dump() time — pre-existing on main, not a regression (reviewer-verified). Clamping to datetime.max/min UTC in _to_utc would fix serialization too, at ≤1 day precision cost on a year-9999 timestamp; happy to do that if preferred.
Patch (git am–ready, 5 files, +93/−9)
From 31ca3c03bc721058d55927980082399cbee1290a Mon Sep 17 00:00:00 2001
From: strandly-the-agent <strandly-the-agent@users.noreply.github.com>
Date: Mon, 24 Aug 2026 19:03:36 +0000
Subject: [PATCH] fix: make SessionMapper.parse_timestamp always return
timezone-aware UTC
Naive datetime passthrough and offset-less ISO strings previously came
back naive, so a single mapped session could mix naive and aware
start_times and any comparison/sort across spans raised TypeError.
Add a _to_utc helper next to _to_aware_utc (naive input assumed UTC,
aware input converted to UTC, with an OverflowError guard for
datetime.min/max across non-UTC offsets) and use it in the datetime
passthrough and fromisoformat branches, making the docstring contract
true for every input shape.
Fixes #372
---
src/strands_evals/mappers/session_mapper.py | 14 +++++---
src/strands_evals/types/trace.py | 15 +++++++-
.../mappers/test_adk_otel_session_mapper.py | 36 ++++++++++++++++++-
.../test_openinference_session_mapper.py | 22 ++++++++++--
tests/strands_evals/types/test_trace.py | 15 ++++++++
5 files changed, 93 insertions(+), 9 deletions(-)
diff --git a/src/strands_evals/mappers/session_mapper.py b/src/strands_evals/mappers/session_mapper.py
index f66fb37..f2f81e5 100644
--- a/src/strands_evals/mappers/session_mapper.py+++ b/src/strands_evals/mappers/session_mapper.py@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from typing_extensions import Any
-from ..types.trace import Session+from ..types.trace import Session, _to_utc
class SessionMapper(ABC):
@@ -79,28 +79,32 @@ class SessionMapper(ABC):
Handles:
- None → current UTC time
- - datetime → passthrough+ - datetime → normalized to UTC
- ISO 8601 string (with optional trailing Z) → parsed datetime
- Numeric (int/float) nanosecond epoch → datetime
- String-encoded nanosecond epoch → datetime
+ Naive datetimes and offset-less ISO strings are assumed to be UTC.+
Args:
value: Raw timestamp value from a span dict.
Returns:
- Timezone-aware datetime in UTC.+ Timezone-aware datetime in UTC (instants at the datetime.min/max+ boundary keep their original offset, since UTC conversion would+ overflow).
"""
if value is None:
return datetime.now(timezone.utc)
if isinstance(value, datetime):
- return value+ return _to_utc(value)
if isinstance(value, str):
if value.isdigit():
return datetime.fromtimestamp(int(value) / 1e9, tz=timezone.utc)
try:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
- return datetime.fromisoformat(value)+ return _to_utc(datetime.fromisoformat(value))
except ValueError:
return datetime.now(timezone.utc)
if isinstance(value, (int, float)):
diff --git a/src/strands_evals/types/trace.py b/src/strands_evals/types/trace.py
index 7dab289..1a54e5f 100644
--- a/src/strands_evals/types/trace.py+++ b/src/strands_evals/types/trace.py@@ -126,12 +126,25 @@ SpanUnion: TypeAlias = InferenceSpan | ToolExecutionSpan | AgentInvocationSpan
def _to_aware_utc(dt: datetime) -> datetime:
- """Normalize a datetime to timezone-aware UTC."""+ """Make a naive datetime timezone-aware by assuming UTC; aware datetimes are returned unchanged."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
+def _to_utc(dt: datetime) -> datetime:+ """Normalize a datetime to UTC, assuming UTC for naive input; instants at the+ datetime.min/max boundary keep their original offset, since conversion would overflow.+ """+ dt = _to_aware_utc(dt)+ try:+ return dt.astimezone(timezone.utc)+ except (ValueError, OverflowError):+ # Converting near datetime.min/max across a non-UTC offset can overflow;+ # keep the aware original rather than fail the whole mapping.+ return dt++
def _find_root_agent_span(agent_spans: Sequence[AgentInvocationSpan]) -> AgentInvocationSpan | None:
"""Search for a trace's root agent span.
diff --git a/tests/strands_evals/mappers/test_adk_otel_session_mapper.py b/tests/strands_evals/mappers/test_adk_otel_session_mapper.py
index 126085e..4b85f61 100644
--- a/tests/strands_evals/mappers/test_adk_otel_session_mapper.py+++ b/tests/strands_evals/mappers/test_adk_otel_session_mapper.py@@ -1,7 +1,7 @@
"""Tests for ADKOtelSessionMapper - ADK OTel spans → Session conversion."""
import json
-from datetime import datetime, timezone+from datetime import datetime, timedelta, timezone
from pathlib import Path
from strands_evals.mappers import ADKOtelSessionMapper
@@ -745,6 +745,40 @@ class TestTimestampParsing:
dt = datetime(2024, 1, 1, tzinfo=timezone.utc)
assert self.mapper.parse_timestamp(dt) == dt
+ def test_none_returns_aware_utc(self):+ assert self.mapper.parse_timestamp(None).tzinfo == timezone.utc++ def test_naive_datetime_assumed_utc(self):+ """Naive datetime input is normalized to timezone-aware UTC."""+ ts = self.mapper.parse_timestamp(datetime(2024, 1, 1))+ assert ts == datetime(2024, 1, 1, tzinfo=timezone.utc)+ assert ts.tzinfo == timezone.utc++ def test_offset_less_iso_string_assumed_utc(self):+ """ISO string without offset is normalized to timezone-aware UTC."""+ ts = self.mapper.parse_timestamp("2024-01-01T00:00:00")+ assert ts == datetime(2024, 1, 1, tzinfo=timezone.utc)+ assert ts.tzinfo == timezone.utc++ def test_iso_string_with_offset_converted_to_utc(self):+ """ISO string with a non-UTC offset is converted to UTC."""+ ts = self.mapper.parse_timestamp("2024-01-01T02:00:00+02:00")+ assert ts == datetime(2024, 1, 1, tzinfo=timezone.utc)+ assert ts.tzinfo == timezone.utc++ def test_aware_non_utc_datetime_converted_to_utc(self):+ """Aware non-UTC datetime is converted to UTC, preserving the instant."""+ dt = datetime(2024, 1, 1, 2, tzinfo=timezone(timedelta(hours=2)))+ ts = self.mapper.parse_timestamp(dt)+ assert ts == datetime(2024, 1, 1, tzinfo=timezone.utc)+ assert ts.tzinfo == timezone.utc++ def test_boundary_instants_do_not_overflow(self):+ """ISO strings at the datetime.min/max boundary stay parseable (aware, original offset)."""+ for raw in ("9999-12-31T23:59:59-05:00", "0001-01-01T00:00:00+02:00"):+ ts = self.mapper.parse_timestamp(raw)+ assert ts.tzinfo is not None+
# ============================================================================
# Tests: Error Handling
diff --git a/tests/strands_evals/mappers/test_openinference_session_mapper.py b/tests/strands_evals/mappers/test_openinference_session_mapper.py
index c5c3a0d..2aa3ca4 100644
--- a/tests/strands_evals/mappers/test_openinference_session_mapper.py+++ b/tests/strands_evals/mappers/test_openinference_session_mapper.py@@ -1,6 +1,7 @@
"""Tests for OpenInferenceSessionMapper - OpenInference LangChain trace → Session conversion."""
import json
+from datetime import datetime
from pathlib import Path
import pytest
@@ -2404,7 +2405,7 @@ class TestOpenAIAgentsScopeSupport:
assert len(tool_spans) == 1
def test_normalization_survives_heterogeneous_start_times(self):
- """Descendant LLM sort tolerates mixed start_time types (int / ISO str / None).+ """Descendant LLM sort tolerates mixed start_time types (int / ISO str / naive datetime / None).
parse_timestamp normalizes each to a comparable datetime, so the sort no
longer raises TypeError on a group with heterogeneous timestamps.
@@ -2458,9 +2459,26 @@ class TestOpenAIAgentsScopeSupport:
attributes=dict(llm_common),
start_time="2024-01-01T00:00:00Z",
) # ISO string
+ turn_c = make_span(+ trace_id="t1",+ span_id="turn-c",+ parent_span_id="agent-1",+ name="turn",+ scope_name=OPENAI_AGENTS_SCOPE_NAME,+ attributes={"openinference.span.kind": "CHAIN"},+ )+ llm_c = make_span(+ trace_id="t1",+ span_id="llm-c",+ parent_span_id="turn-c",+ name="response",+ scope_name=OPENAI_AGENTS_SCOPE_NAME,+ attributes=dict(llm_common),+ start_time=datetime(2024, 1, 2),+ ) # naive datetime — the #372 case that used to raise TypeError in the sort
# Should not raise, and the agent span is still produced.
- session = self.mapper.map_to_session([agent, turn_a, llm_a, turn_b, llm_b], "sess-1")+ session = self.mapper.map_to_session([agent, turn_a, llm_a, turn_b, llm_b, turn_c, llm_c], "sess-1")
agent_spans = [s for t in session.traces for s in t.spans if isinstance(s, AgentInvocationSpan)]
assert len(agent_spans) == 1
assert agent_spans[0].user_prompt == "What is 5 + 3?"
diff --git a/tests/strands_evals/types/test_trace.py b/tests/strands_evals/types/test_trace.py
index a30731f..3cb41a4 100644
--- a/tests/strands_evals/types/test_trace.py+++ b/tests/strands_evals/types/test_trace.py@@ -20,6 +20,7 @@ from strands_evals.types.trace import (
TraceLevelInput,
UserMessage,
_find_root_agent_span,
+ _to_utc,
)
@@ -427,3 +428,17 @@ class TestFindRootAgentSpan:
_root_agent("early", parent="p", offset=1, prompt="y"),
]
assert _find_root_agent_span(spans).span_info.span_id == "early"
+++class TestToUtc:+ def test_naive_assumed_utc(self):+ assert _to_utc(datetime(2024, 1, 1)) == datetime(2024, 1, 1, tzinfo=timezone.utc)++ def test_aware_converted_to_utc(self):+ dt = datetime(2024, 1, 1, 2, tzinfo=timezone(timedelta(hours=2)))+ assert _to_utc(dt) == datetime(2024, 1, 1, tzinfo=timezone.utc)+ assert _to_utc(dt).tzinfo == timezone.utc++ def test_keeps_aware_original_when_conversion_overflows(self):+ dt = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone(timedelta(hours=-5)))+ assert _to_utc(dt) == dt # no OverflowError; still aware, hence comparable--
2.39.5
Activity
liramon2 commentedon Aug 24, 2026
@strandly-the-agent Implement the fix for this bug and make a PR for it.
strandly-the-agent commentedon Aug 24, 2026
@liramon2 The fix is implemented, tested, and independently reviewed (two fresh-context review rounds, both APPROVE) — but I can't open the PR myself: pushing to this repo returns 403, I have no fork of
evals, and fork creation is blocked by my guardrails (same access gap as in #355, still unresolved — no collaborator invite pending). So the apply-ready patch is below instead.To apply: save the collapsed patch as
fix-372.patchandgit am fix-372.patch. Or grantstrandly-the-agenta fork/collaborator path and I'll open the PR properly.What it does: adds a
_to_utchelper next to_to_aware_utcintypes/trace.py(naive → assume UTC, aware → convert to UTC, with anOverflowErrorguard fordatetime.min/maxacross non-UTC offsets) and uses it inparse_timestamp's datetime-passthrough andfromisoformatbranches — every branch now honors the "timezone-aware UTC" docstring, so mapped sessions can no longer mix naive and awarestart_times.Verification (all on the patch commit, Python 3.13)
pytest tests/, all extras installed): 2130 passed, 0 failedruff check src/ tests/+ruff format --checkon touched files: cleanmain's source → 5 failed / 6 passed (the mixed-session openinference test fails onmainwith the agent span silently dropped — the sort'sTypeErroris swallowed by_build_trace's broadexcept, so [BUG] SessionMapper.parse_timestamp returns naive datetimes despite docstring promising timezone-aware UTC — mixed sessions break datetime comparisons #372 was silently eating spans, not just raising)parse_timestamp("2024-01-01T00:00:00").tzinfo,parse_timestamp(datetime(2024,1,1)).tzinfo→UTC; sorting mixed spans no longer raisesIndependent review ledger (2 rounds, fresh context each)
Round 1 — APPROVE, 4 non-blocking findings; 3 taken, 1 deferred:
astimezonecould raiseOverflowErrorfor boundary instants with non-UTC offsets (a regression vsmain, where such strings parsed) → guarded in_to_utc_to_aware_utcdocstring overstated its behavior ("normalize to UTC" — it only attaches UTC to naive input) → correctedstart_timetestTestTimestampParsingto a newtest_session_mapper.py(base-class behavior is tested in the ADK file). Kept in place to match the existing convention and keep the diff minimal; a follow-up can relocate it per AGENTS.md's mirror-the-src rule.Round 2 — APPROVE, 1 🟡 + 4 ⚪; 🟡 and one ⚪ taken, rest declined as out of scope for a minimal bugfix:
TestToUtcunit tests + boundary ISO casesReturns:docstring was false on the boundary path → documented the exception_to_aware_utc→_ensure_aware, dedupe withserialize_datetime_utc, renametest_datetime_passthrough— all widen the diff without fixing behaviorserialize_datetime_utcstill raises on them atmodel_dump()time — pre-existing onmain, not a regression (reviewer-verified). Clamping todatetime.max/minUTC in_to_utcwould fix serialization too, at ≤1 day precision cost on a year-9999 timestamp; happy to do that if preferred.Patch (git am–ready, 5 files, +93/−9)