Skip to content

fix(tests): order test spans by start sequence, not by started_at alone - #4392

Merged
seratch merged 2 commits into
openai:mainfrom
ErenAta16:fix/order-test-spans-by-start-sequence
Aug 14, 2026
Merged

seratch merged 2 commits into
openai:mainfrom
ErenAta16:fix/order-test-spans-by-start-sequence

Conversation

@ErenAta16

Copy link
Copy Markdown
Contributor

On a clean checkout on Windows with Python 3.10, 3.11 or 3.12, pytest fails 37 tests across the tracing, MCP-tracing and runtime-symmetry suites, all with the same error:

KeyError: ('trace_...', 'span_...')
tests/testing_processor.py:162: KeyError

Nothing is wrong with the library. The test helper's span ordering depends on clock resolution.

Mechanism

Three steps, each verifiable on its own.

1. started_at ties. It comes from datetime.now() via agents.tracing.util.time_iso. On Windows the resolution of that call changed in Python 3.13, measured on this machine over a 1000-call burst:

Python distinct timestamps per 1000 calls consecutive calls that tie
3.10.19 2 998 / 999
3.11.14 2 998 / 999
3.12.12 2 998 / 999
3.13.11 1000 0 / 999

So before 3.13, a parent span and the child it opens almost always carry the identical started_at.

2. A tie resolves to the wrong order. get_ordered_spans sorted on that field alone:

return sorted(spans, key=lambda x: x.started_at or 0)

sorted is stable, so a tie preserves insertion order, and spans are inserted in on_span_end. A child ends before its parent, so a tie puts the child first, which is the exact reverse of what the consumer needs.

3. The consumer requires parent-first. fetch_normalized_spans looks the parent up while attaching each span:

nodes[(trace_id, parent_id)].setdefault("children", []).append(span)

Reduced to three spans, with nothing else running:

get_ordered_spans() order:
  0: name='child'   started_at=2026-08-13T13:06:13.769939+00:00  parent=45147e
  1: name='parent'  started_at=2026-08-13T13:06:13.769939+00:00  parent=-

all started_at identical: True
spans appearing before their parent: [('child', 'parent')]
fetch_normalized_spans() -> KeyError: ('trace_4f489b…', 'span_89ba…')

Why CI does not catch it

tests-windows runs the full suite, but pins python-version: "3.13", the one version on Windows where the clock is fine. The Linux matrix covers 3.10 through 3.14 and is unaffected because datetime.now() there has microsecond resolution. So the gap is Windows on any supported version below 3.13, which no job covers.

I have not touched the workflow matrix in this PR; widening it is a separate decision and this fix stands on its own.

Fix

on_span_start already fires for every span and was discarding it. Record the start sequence there and use it only to break timestamp ties:

self._start_order.setdefault(span.span_id, len(self._start_order))
...
return sorted(
    spans,
    key=lambda x: (x.started_at or "", self._start_order.get(x.span_id, 0)),
)

Timestamp order stays primary, so behaviour is unchanged wherever the clock already separates spans. The map is cleared in clear() alongside the other per-test state.

Verification

Same machine, same checkout, the six affected test files:

before after
Windows, Python 3.12 37 failed, 54 passed 91 passed
Windows, Python 3.13 91 passed 91 passed

Whole suite on Python 3.12 goes from 42 failed / 5620 passed to 5 failed / 5657 passed. The 5 that remain are unrelated to span ordering, they fail with and without this change, and some are flaky between runs; I have not chased them here.

ruff check, ruff format --check and mypy are clean on both files.

The regression test

tests/tracing/test_span_ordering.py freezes time_iso rather than relying on a coarse clock, so it reproduces the ordering deterministically on every platform instead of only where the bug happens to bite. Verified red before green: both cases fail with KeyError against the unfixed helper on Python 3.13, where the real bug cannot otherwise be observed at all.

One case asserts the ordering contract directly (no span precedes its parent) and the other asserts the tree fetch_normalized_spans builds from it, so a future change that satisfies one but not the other still fails.

`SpanProcessorForTests.get_ordered_spans` sorted only on `started_at`, which
comes from `datetime.now()`. On Windows before Python 3.13 that clock advances
in ~15ms steps, so a parent span and its child routinely carry the identical
timestamp. The sort is stable, so a tie falls back to insertion order, and
spans are inserted in `on_span_end` order: a child ends before its parent, so
the child came out first.

`fetch_normalized_spans` builds the tree by looking up each span's parent, so a
child arriving first raised `KeyError: (trace_id, span_id)`. That took out 37
tests across the tracing, MCP-tracing and runtime-symmetry suites on a clean
checkout.

`on_span_start` already fires for every span and was discarding it, so record
the start sequence there and use it to break timestamp ties. Timestamp order is
still primary, so nothing changes where the clock has enough resolution to
separate spans.

The regression test freezes `time_iso` instead of relying on a coarse clock, so
it reproduces the ordering deterministically on every platform.

(cherry picked from commit b3c5713)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 30da5c9595

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/testing_processor.py Resolved

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed reproduction. Before merge, please key _start_order by (span.trace_id, span.span_id) rather than span_id alone, and add a tied-clock regression test with two traces that reuse an explicit span ID.

span_id is only unique within a trace, and the test helper already treats span identity as (trace_id, span_id). With the current head, the later trace can inherit the earlier trace's sequence, causing its child to remain ahead of its parent and fetch_normalized_spans() to raise KeyError. After that focused fix and regression test, this should be ready for another review.

A span ID is only unique within its trace and `span_id` is a caller-settable
parameter on every span factory, so recording start order per span ID alone let
a later trace inherit an earlier trace's position.

Reproduced: trace A uses an explicit span ID for a span that starts early, trace
B reuses it for a span that starts late. B's child kept A's index, sorted ahead
of its own parent, and `fetch_normalized_spans()` raised KeyError on B's parent.

The helper already treats span identity as `(trace_id, span_id)` when it builds
the tree, so key the start order the same way. The parent-first assertion in the
regression tests now compares on the pair too.
@ErenAta16

Copy link
Copy Markdown
Contributor Author

You are right, and it fails exactly the way you described. Reproduced it before changing anything, since "span IDs collide" sounded unlikely until I checked that span_id is a caller-settable parameter on every span factory:

def agent_span(name: str, ..., span_id: str | None = None) -> Span[AgentSpanData]:

First trace uses an explicit ID for a span that starts early, second reuses it for one that starts late, clock frozen so every started_at ties:

get_ordered_spans(), keyed by span_id alone:
  trace=5d78  A-parent   span=000000  parent=-
  trace=f790  B-child    span=000000  parent=e3207c     <- B's child, holding A's index 0
  trace=5d78  A-child    span=058b3b  parent=000000
  trace=f790  B-parent   span=e3207c  parent=-

spans ordered before their parent: [('f790', 'B-child')]
fetch_normalized_spans(): KeyError ('trace_...406df790', 'span_...94e3207c')

setdefault on the bare span ID keeps the first trace's position, so the second trace's child sits at index 0 while its own parent gets a fresh, higher index. Keyed by (trace_id, span_id):

  trace=b13a  A-parent   span=000000  parent=-
  trace=b13a  A-child    span=4eab38  parent=000000
  trace=363a  B-parent   span=4cc447  parent=-
  trace=363a  B-child    span=000000  parent=4cc447

spans ordered before their parent: []
fetch_normalized_spans(): OK

Pushed in 3d21812:

  • _start_order is now dict[tuple[str, str], int], set and read on (trace_id, span_id), matching how the helper already keys nodes when it builds the tree.
  • Added test_two_traces_reusing_one_span_id_keep_their_own_order, which is the case above.
  • The parent-first assertion moved into _assert_every_parent_comes_first and now compares on (trace_id, parent_id) too. It was tracking bare span IDs, which would have let this same class of bug through the assertion even once the ordering was right.

The new test is discriminating rather than incidental: reverting only the keying, leaving everything else in place, fails that one test and leaves the other two green.

with the fix                          3 passed
keying reverted to span_id alone      1 failed, 2 passed

tests/tracing, test_agent_tracing.py, test_tracing.py, test_tracing_errors.py, test_tracing_errors_streamed.py and mcp/test_mcp_tracing.py are 109 passed on the branch. ruff check, ruff format --check and mypy clean on both files.

Worth noting the branch is a few commits behind main now, so test_runtime_symmetry_contract.py is not on it yet; that file was in the original 37 and is covered by the merge result rather than by my local run. Happy to rebase if you would rather see it green on the branch itself.

@seratch
seratch enabled auto-merge (squash) August 14, 2026 09:30
@seratch
seratch merged commit 3e87dc8 into openai:main Aug 14, 2026
14 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.

2 participants