* Ship a one-shot `copilot.sse.read_loop` OTel span with the aggregate * counters collected during the read loop. Uses `startTime` so the * span's duration reflects the actual loop wall clock even though we * only talk to OTel once at the end. * * Deliberately synchronous, no per-chunk span cal
(
startPerfMs: number,
counters: SseReadLoopCounters,
closeReason: string,
fetchUrl: string,
pathname: string,
opts: { idleGapEventThresholdMs: number; expectedTerminal: boolean }
)
| 607 | * chunk count. |
| 608 | */ |
| 609 | function stampSseReadLoopSpan( |
| 610 | startPerfMs: number, |
| 611 | counters: SseReadLoopCounters, |
| 612 | closeReason: string, |
| 613 | fetchUrl: string, |
| 614 | pathname: string, |
| 615 | opts: { idleGapEventThresholdMs: number; expectedTerminal: boolean } |
| 616 | ): void { |
| 617 | // Translate performance.now() values into wall-clock Date values so |
| 618 | // the span's timestamps land in real time (OTel accepts both, but we |
| 619 | // need to pair startTime with a matching "now" for .end()). |
| 620 | const nowPerf = performance.now() |
| 621 | const nowWall = Date.now() |
| 622 | const startWall = nowWall - (nowPerf - startPerfMs) |
| 623 | |
| 624 | const terminalEventSeen = counters.eventsByType.complete > 0 || counters.eventsByType.error > 0 |
| 625 | // `terminal_event_missing` is the single-attribute dashboard signal |
| 626 | // for the "disappeared response" bug class: the caller considered |
| 627 | // this leg to be the final one (`context.streamComplete === true`) |
| 628 | // but no terminal `complete` or `error` event arrived on the wire. |
| 629 | // Tool-pause legs have expectedTerminal=false and never trip this, so |
| 630 | // dashboards can filter on `{ .copilot.sse.terminal_event_missing = true }` |
| 631 | // without false positives. |
| 632 | const terminalEventMissing = opts.expectedTerminal && !terminalEventSeen |
| 633 | |
| 634 | const tracer = getCopilotTracer() |
| 635 | const span = tracer.startSpan(TraceSpan.CopilotSseReadLoop, { |
| 636 | startTime: startWall, |
| 637 | attributes: { |
| 638 | [TraceAttr.HttpUrl]: fetchUrl, |
| 639 | [TraceAttr.HttpPath]: pathname, |
| 640 | [TraceAttr.CopilotSseBytesReceived]: counters.bytes, |
| 641 | [TraceAttr.CopilotSseChunksReceived]: counters.chunks, |
| 642 | [TraceAttr.CopilotSseEventsReceived]: counters.events, |
| 643 | [TraceAttr.CopilotSseEventsSession]: counters.eventsByType.session, |
| 644 | [TraceAttr.CopilotSseEventsText]: counters.eventsByType.text, |
| 645 | [TraceAttr.CopilotSseEventsTool]: counters.eventsByType.tool, |
| 646 | [TraceAttr.CopilotSseEventsSpan]: counters.eventsByType.span, |
| 647 | [TraceAttr.CopilotSseEventsResource]: counters.eventsByType.resource, |
| 648 | [TraceAttr.CopilotSseEventsRun]: counters.eventsByType.run, |
| 649 | [TraceAttr.CopilotSseEventsError]: counters.eventsByType.error, |
| 650 | [TraceAttr.CopilotSseEventsComplete]: counters.eventsByType.complete, |
| 651 | [TraceAttr.CopilotSseLongestInboundGapMs]: Math.round(counters.longestInboundGapMs), |
| 652 | [TraceAttr.CopilotSseLongestDispatchMs]: Math.round(counters.longestDispatchMs), |
| 653 | [TraceAttr.CopilotSseTotalDispatchMs]: Math.round(counters.totalDispatchMs), |
| 654 | [TraceAttr.CopilotSseCloseReason]: closeReason, |
| 655 | [TraceAttr.CopilotSseExpectedTerminal]: opts.expectedTerminal, |
| 656 | [TraceAttr.CopilotSseTerminalEventSeen]: terminalEventSeen, |
| 657 | [TraceAttr.CopilotSseTerminalEventMissing]: terminalEventMissing, |
| 658 | }, |
| 659 | }) |
| 660 | |
| 661 | if (counters.firstEventMs !== undefined) { |
| 662 | span.setAttribute(TraceAttr.CopilotSseFirstEventMs, counters.firstEventMs) |
| 663 | // Anchor the event to the moment the first SSE event was actually |
| 664 | // received (startWall + firstEventMs), not `now`, so a trace |
| 665 | // waterfall shows the diamond at the TTFT point — not at span end. |
| 666 | span.addEvent( |
no test coverage detected