@@ -166,7 +166,7 @@ async def test_sequential_runs_unique_ids(self, trace_manager, otlp_client):
166166 await send_traces (otlp_client , body )
167167 await wait_for_session_complete (trace_manager , "repeated" )
168168
169- # Second run — same name but first is complete
169+ # Second run — same name but new trace_id → new session
170170 body = make_trace_request (
171171 trace_id = "run2" ,
172172 session_name = "repeated" ,
@@ -317,3 +317,170 @@ async def test_span_limit_enforcement(self, trace_manager, otlp_client):
317317 await send_traces (otlp_client , body2 )
318318
319319 assert len (session .spans ) == MAX_SPANS_PER_SESSION
320+
321+
322+ class TestSplitBatchReopen :
323+ """Regression tests for the split-batch scenario where the OTLP
324+ BatchSpanProcessor flushes one trace's spans across the session
325+ completion boundary. Some child spans arrive before the grace period
326+ fires, the root span arrives after. Because the trace_id was already
327+ registered in the session, the session is reopened.
328+
329+ Bug report: strands-zero-code vs strands-zero-code-2 — a 3-turn
330+ conversation was broken into 2 sessions because turn 3's spans
331+ were split across the completion boundary."""
332+
333+ async def test_split_trace_reopens_completed_session (
334+ self , trace_manager , otlp_client
335+ ):
336+ """When child spans arrive before completion and the root span
337+ arrives after, the session reopens because the trace_id already
338+ exists in the session."""
339+ session_name = "split-reopen"
340+
341+ # Batch 1: turn 1 root + turn 2 child spans in same flush
342+ await send_traces (otlp_client , make_trace_request (
343+ trace_id = "sr-t1" , session_name = session_name ,
344+ spans = [
345+ make_genai_span (trace_id = "sr-t1" , span_id = "t1-root" , parent_span_id = None ),
346+ make_genai_span (trace_id = "sr-t2" , span_id = "t2-child" ,
347+ parent_span_id = "t2-root" ),
348+ ],
349+ ))
350+ await wait_for_session_complete (trace_manager , session_name )
351+
352+ session = trace_manager .sessions [session_name ]
353+ assert session .is_complete
354+ assert "sr-t2" in session .trace_ids
355+
356+ # Batch 2: turn 2 root span arrives after completion
357+ await send_traces (otlp_client , make_trace_request (
358+ trace_id = "sr-t2" , session_name = session_name ,
359+ spans = [
360+ make_genai_span (trace_id = "sr-t2" , span_id = "t2-root" , parent_span_id = None ),
361+ ],
362+ ))
363+
364+ assert not session .is_complete
365+ assert len (trace_manager .sessions ) == 1
366+ assert session .trace_ids == {"sr-t1" , "sr-t2" }
367+
368+ await wait_for_session_complete (trace_manager , session_name )
369+ assert session .is_complete
370+ assert len (session .spans ) == 3
371+
372+ async def test_strands_three_turn_bug_repro (
373+ self , trace_manager , otlp_client
374+ ):
375+ """Reproduces the exact bug from the Strands SDK report: the
376+ BatchSpanProcessor flushes turns 1-2 and partial turn 3 in one
377+ batch, then the rest of turn 3 in a second batch after the
378+ session completes. All spans must end up in a single session."""
379+ session_name = "strands-repro"
380+
381+ # Batch 1: turns 1 & 2 fully, plus turn 3 child spans
382+ await send_traces (otlp_client , make_trace_request (
383+ trace_id = "t1" , session_name = session_name ,
384+ spans = [
385+ make_genai_span (trace_id = "t1" , span_id = "t1-llm" , parent_span_id = "t1-root" ),
386+ make_genai_span (trace_id = "t1" , span_id = "t1-root" , parent_span_id = None ,
387+ name = "invoke_agent" ),
388+ make_genai_span (trace_id = "t2" , span_id = "t2-llm" , parent_span_id = "t2-root" ),
389+ make_genai_span (trace_id = "t2" , span_id = "t2-tool" ,
390+ parent_span_id = "t2-root" , name = "execute_tool roll_die" ),
391+ make_genai_span (trace_id = "t2" , span_id = "t2-root" , parent_span_id = None ,
392+ name = "invoke_agent" ),
393+ # Turn 3 child spans — flushed in same batch
394+ make_genai_span (trace_id = "t3" , span_id = "t3-llm" , parent_span_id = "t3-root" ),
395+ make_genai_span (trace_id = "t3" , span_id = "t3-tool" ,
396+ parent_span_id = "t3-root" , name = "execute_tool check_prime" ),
397+ ],
398+ ))
399+ await wait_for_session_complete (trace_manager , session_name )
400+
401+ session = trace_manager .sessions [session_name ]
402+ assert session .is_complete
403+ assert "t3" in session .trace_ids
404+
405+ # Batch 2: turn 3 root span + remaining spans (after completion)
406+ await send_traces (otlp_client , make_trace_request (
407+ trace_id = "t3" , session_name = session_name ,
408+ spans = [
409+ make_genai_span (trace_id = "t3" , span_id = "t3-loop" ,
410+ parent_span_id = "t3-root" , name = "execute_event_loop_cycle" ),
411+ make_genai_span (trace_id = "t3" , span_id = "t3-root" , parent_span_id = None ,
412+ name = "invoke_agent" ),
413+ ],
414+ ))
415+
416+ assert not session .is_complete
417+ await wait_for_session_complete (trace_manager , session_name )
418+
419+ assert len (trace_manager .sessions ) == 1
420+ assert session .trace_ids == {"t1" , "t2" , "t3" }
421+ assert len (session .spans ) == 9
422+
423+ async def test_new_trace_after_completion_creates_new_session (
424+ self , trace_manager , otlp_client
425+ ):
426+ """A completely new trace_id after session completion creates a
427+ new session (not a reopen). This is the re-run case."""
428+ session_name = "no-reopen"
429+
430+ await send_traces (otlp_client , make_trace_request (
431+ trace_id = "run-1" , session_name = session_name ,
432+ spans = [make_genai_span (trace_id = "run-1" , parent_span_id = None )],
433+ ))
434+ await wait_for_session_complete (trace_manager , session_name )
435+
436+ # New trace_id (not seen before) → new session
437+ await send_traces (otlp_client , make_trace_request (
438+ trace_id = "run-2" , session_name = session_name ,
439+ spans = [make_genai_span (trace_id = "run-2" , parent_span_id = None )],
440+ ))
441+ await wait_for_session_complete (trace_manager , f"{ session_name } -2" )
442+
443+ assert len (trace_manager .sessions ) == 2
444+ assert trace_manager .sessions [session_name ].trace_ids == {"run-1" }
445+ assert trace_manager .sessions [f"{ session_name } -2" ].trace_ids == {"run-2" }
446+
447+ async def test_reopen_preserves_existing_spans_and_logs (
448+ self , trace_manager , otlp_client
449+ ):
450+ """Reopening a session preserves all previously collected spans and logs."""
451+ session_name = "preserve"
452+
453+ # Send spans and logs with trace_id "pres-t1", PLUS a child span
454+ # from "pres-t2" so its trace_id is registered for reopen
455+ await send_traces (otlp_client , make_trace_request (
456+ trace_id = "pres-t1" , session_name = session_name ,
457+ spans = [
458+ make_genai_span (trace_id = "pres-t1" , parent_span_id = None ),
459+ make_genai_span (trace_id = "pres-t2" , span_id = "t2-child" ,
460+ parent_span_id = "t2-root" ),
461+ ],
462+ ))
463+ await send_logs (otlp_client , make_log_request (
464+ trace_id = "pres-t1" , session_name = session_name ,
465+ log_records = [
466+ make_genai_log ("gen_ai.user.message" , "Turn 1" , trace_id = "pres-t1" ),
467+ ],
468+ ))
469+ await wait_for_session_complete (trace_manager , session_name )
470+
471+ session = trace_manager .sessions [session_name ]
472+ spans_before = len (session .spans )
473+ logs_before = len (session .logs )
474+ assert spans_before >= 2
475+ assert logs_before >= 1
476+
477+ # Reopen via split-batch trace_id match
478+ await send_traces (otlp_client , make_trace_request (
479+ trace_id = "pres-t2" , session_name = session_name ,
480+ spans = [make_genai_span (trace_id = "pres-t2" , span_id = "t2-root" ,
481+ parent_span_id = None )],
482+ ))
483+ await wait_for_session_complete (trace_manager , session_name )
484+
485+ assert len (session .spans ) == spans_before + 1
486+ assert len (session .logs ) == logs_before
0 commit comments