|
15 | 15 | from ..api.models import ( |
16 | 16 | SessionInfo, |
17 | 17 | WSSessionCompleteEvent, |
| 18 | + WSSessionRemovedEvent, |
18 | 19 | WSSessionStartedEvent, |
19 | 20 | WSSpanReceivedEvent, |
20 | 21 | ) |
@@ -227,14 +228,21 @@ async def get_or_create_otlp_session(self, trace_id: str, metadata: dict) -> Tra |
227 | 228 | active = self.sessions.get(active_id) |
228 | 229 | if active and not active.is_complete: |
229 | 230 | active.trace_ids.add(trace_id) |
| 231 | + if conversation_id: |
| 232 | + await self._absorb_orphan_for_trace(trace_id, active) |
230 | 233 | return active |
231 | 234 | if active and active.is_complete and conversation_id: |
232 | 235 | self._reopen_session(active, trace_id, session_name) |
| 236 | + await self._absorb_orphan_for_trace(trace_id, active) |
233 | 237 | return active |
234 | 238 |
|
235 | 239 | existing = self.find_session_by_trace_id(trace_id) |
236 | | - if existing and existing.is_complete: |
237 | | - self._reopen_session(existing, trace_id, session_name) |
| 240 | + if existing: |
| 241 | + if existing.is_complete: |
| 242 | + self._reopen_session(existing, trace_id, session_name) |
| 243 | + else: |
| 244 | + existing.trace_ids.add(trace_id) |
| 245 | + self._active_session_for_name[session_name] = existing.session_id |
238 | 246 | return existing |
239 | 247 |
|
240 | 248 | session_id = session_name |
@@ -349,6 +357,55 @@ def _reopen_session(self, session: TraceSession, trace_id: str, session_name: st |
349 | 357 | len(session.spans), |
350 | 358 | ) |
351 | 359 |
|
| 360 | + async def _absorb_orphan_for_trace(self, trace_id: str, target: TraceSession) -> None: |
| 361 | + """Merge an orphan session into the target when conversation_id is discovered. |
| 362 | +
|
| 363 | + When infrastructure spans (no conversation_id) arrive before agent spans, |
| 364 | + they create a separate session keyed by trace_id. Once the conversation_id |
| 365 | + is known and routes to the correct session, the orphan's data is merged |
| 366 | + and the orphan session is removed. |
| 367 | + """ |
| 368 | + orphan = None |
| 369 | + orphan_id = None |
| 370 | + for sid, session in self.sessions.items(): |
| 371 | + if sid == target.session_id: |
| 372 | + continue |
| 373 | + if trace_id in session.trace_ids: |
| 374 | + orphan = session |
| 375 | + orphan_id = sid |
| 376 | + break |
| 377 | + |
| 378 | + if not orphan: |
| 379 | + return |
| 380 | + |
| 381 | + target.spans.extend(orphan.spans) |
| 382 | + target.logs.extend(orphan.logs) |
| 383 | + target.trace_ids.update(orphan.trace_ids) |
| 384 | + if orphan.has_root_span: |
| 385 | + target.has_root_span = True |
| 386 | + |
| 387 | + del self.sessions[orphan_id] |
| 388 | + for name, mapped_id in list(self._active_session_for_name.items()): |
| 389 | + if mapped_id == orphan_id: |
| 390 | + del self._active_session_for_name[name] |
| 391 | + for timer_map in (self._completion_timers, self._idle_timers): |
| 392 | + if orphan_id in timer_map: |
| 393 | + timer_map.pop(orphan_id).cancel() |
| 394 | + self.incremental_extractors.pop(orphan_id, None) |
| 395 | + |
| 396 | + await self.broadcast_to_ui( |
| 397 | + WSSessionRemovedEvent( |
| 398 | + session_id=orphan_id, |
| 399 | + absorbed_by=target.session_id, |
| 400 | + ).model_dump(by_alias=True) |
| 401 | + ) |
| 402 | + logger.info( |
| 403 | + "Absorbed orphan session %s (%d spans) into %s", |
| 404 | + orphan_id, |
| 405 | + len(orphan.spans), |
| 406 | + target.session_id, |
| 407 | + ) |
| 408 | + |
352 | 409 | async def _delayed_complete(self, session_id: str, delay: float) -> None: |
353 | 410 | await asyncio.sleep(delay) |
354 | 411 | await self._complete_otlp_session(session_id) |
|
0 commit comments