Skip to content

[BUG] SessionMapper.parse_timestamp returns naive datetimes despite docstring promising timezone-aware UTC — mixed sessions break datetime comparisons #372

Closed

Description

@strandly-the-agent

Checks

  • I have updated to the latest minor and patch version of Strands and evals
  • I have checked the documentation and this is not expected behavior
  • I have searched ./issues and there are no duplicates of my issue

Strands Version

1.52.0

Strands Evals Version

main @ cc0a391 (v1.0.3)

Python Version

3.13.14

Operating System

Linux (aarch64)

Installation Method

git clone

Steps to Reproduce

from strands_evals.mappers.session_mapper import SessionMapper

m = SessionMapper.__new__(SessionMapper)  # any concrete mapper
m.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; :103 datetime.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.

Related Issues

#343

Activity

  1. added
    bugSomething isn't working
    area-tracingTrace/session ingestion: providers, session mappers, extractors, telemetry/OTEL
    area-devxDeveloper experience: papercuts, confusing public APIs, error messages, ergonomics, usability
    on Aug 15, 2026
  2. liramon2 commented on Aug 24, 2026

    @liramon2
    Contributor

    @strandly-the-agent Implement the fix for this bug and make a PR for it.

  3. strandly-the-agent commented on Aug 24, 2026

    @strandly-the-agent
    ContributorAuthor

    @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
    • New tests fail without the fix: reviewer ran them against main's source → 5 failed / 6 passed (the mixed-session openinference test fails on main with the agent span silently dropped — the sort's TypeError is swallowed by _build_trace's broad except, 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)
    • Issue repro: parse_timestamp("2024-01-01T00:00:00").tzinfo, parse_timestamp(datetime(2024,1,1)).tzinfo → UTC; sorting mixed spans no longer raises
    • Mutation check: removing the overflow guard makes the new boundary tests fail (2 failed), confirming they pin it
    Independent review ledger (2 rounds, fresh context each)

    Round 1 — APPROVE, 4 non-blocking findings; 3 taken, 1 deferred:

    • ✅ 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
  4. self-assigned this
    on Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area-devxDeveloper experience: papercuts, confusing public APIs, error messages, ergonomics, usabilityarea-tracingTrace/session ingestion: providers, session mappers, extractors, telemetry/OTELbugSomething isn't working

Type

Fields

Language

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions