1- import type { Span } from '@sentry/core' ;
1+ import type { LRUMap , Span } from '@sentry/core' ;
22import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN , SPAN_STATUS_ERROR , startInactiveSpan , stringify } from '@sentry/core' ;
33import {
44 GEN_AI_CONVERSATION_ID ,
@@ -32,7 +32,7 @@ import {
3232 SERVER_PORT ,
3333} from '@sentry/conventions/attributes' ;
3434import { getGenAiSpanOp } from '../core/utils' ;
35- import { FLUE_ORIGIN } from './constants' ;
35+ import { FLUE_ORIGIN , MAX_TRACKED_FLUE_SPANS } from './constants' ;
3636import type { FlueModelRequestInfo , FlueObservation , FlueUsage } from './types' ;
3737
3838/**
@@ -49,13 +49,36 @@ export function sentryTraceFromTraceparent(traceparent: string): string | undefi
4949 return `${ traceId } -${ spanId } -${ parseInt ( flags , 16 ) % 2 === 1 ? '1' : '0' } ` ;
5050}
5151
52- export function startTurnSpan ( observation : FlueObservation , turnSpans : Map < string , Span > ) : void {
52+ /**
53+ * Turn and tool spans, keyed by the id of the work they cover. Bounded, because the key is only
54+ * removed when the matching end observation arrives and an abandoned stream never emits one.
55+ */
56+ export type SpanTracker = LRUMap < string , Span > ;
57+
58+ /**
59+ * Store a span under `key`. Callers only reach this with a key the tracker does not hold, so the
60+ * one span at risk of being dropped without `end()` is the oldest entry, which `LRUMap.set` silently
61+ * evicts once the tracker is full.
62+ */
63+ function trackSpan ( tracker : SpanTracker , key : string , span : Span ) : void {
64+ if ( tracker . size >= MAX_TRACKED_FLUE_SPANS ) {
65+ const oldestKey = tracker . keys ( ) [ 0 ] ;
66+ if ( oldestKey !== undefined ) {
67+ tracker . remove ( oldestKey ) ?. end ( ) ;
68+ }
69+ }
70+
71+ tracker . set ( key , span ) ;
72+ }
73+
74+ export function startTurnSpan ( observation : FlueObservation , turnSpans : SpanTracker ) : void {
5375 const { turnId } = observation ;
54- if ( ! turnId || turnSpans . has ( turnId ) ) {
76+ if ( ! turnId || turnSpans . get ( turnId ) ) {
5577 return ;
5678 }
5779
58- turnSpans . set (
80+ trackSpan (
81+ turnSpans ,
5982 turnId ,
6083 startInactiveSpan ( {
6184 name : 'chat' ,
@@ -72,13 +95,13 @@ export function startTurnSpan(observation: FlueObservation, turnSpans: Map<strin
7295 ) ;
7396}
7497
75- export function endTurnSpan ( observation : FlueObservation , turnSpans : Map < string , Span > , recordOutputs : boolean ) : void {
98+ export function endTurnSpan ( observation : FlueObservation , turnSpans : SpanTracker , recordOutputs : boolean ) : void {
7699 const { turnId } = observation ;
77100 const span = turnId ? turnSpans . get ( turnId ) : undefined ;
78101 if ( ! span || ! turnId ) {
79102 return ;
80103 }
81- turnSpans . delete ( turnId ) ;
104+ turnSpans . remove ( turnId ) ;
82105
83106 const requestedModel = observation . request ?. requestedModel ;
84107 const responseModel = observation . response ?. responseModel ;
@@ -160,13 +183,14 @@ export function setUsageAttributes(span: Span, usage: FlueUsage | undefined, isE
160183 * OpenTelemetry adapter projects them: siblings of `chat`, correlated to model output by tool call
161184 * id. Keyed by `toolCallId` so concurrent tool calls in one turn cannot cross-attribute.
162185 */
163- export function startToolSpan ( observation : FlueObservation , toolSpans : Map < string , Span > , recordInputs : boolean ) : void {
186+ export function startToolSpan ( observation : FlueObservation , toolSpans : SpanTracker , recordInputs : boolean ) : void {
164187 const { toolCallId, toolName } = observation ;
165- if ( ! toolCallId || toolSpans . has ( toolCallId ) ) {
188+ if ( ! toolCallId || toolSpans . get ( toolCallId ) ) {
166189 return ;
167190 }
168191
169- toolSpans . set (
192+ trackSpan (
193+ toolSpans ,
170194 toolCallId ,
171195 startInactiveSpan ( {
172196 name : `execute_tool ${ toolName ?? 'unknown' } ` ,
@@ -184,13 +208,13 @@ export function startToolSpan(observation: FlueObservation, toolSpans: Map<strin
184208 ) ;
185209}
186210
187- export function endToolSpan ( observation : FlueObservation , toolSpans : Map < string , Span > , recordOutputs : boolean ) : void {
211+ export function endToolSpan ( observation : FlueObservation , toolSpans : SpanTracker , recordOutputs : boolean ) : void {
188212 const { toolCallId } = observation ;
189213 const span = toolCallId ? toolSpans . get ( toolCallId ) : undefined ;
190214 if ( ! span || ! toolCallId ) {
191215 return ;
192216 }
193- toolSpans . delete ( toolCallId ) ;
217+ toolSpans . remove ( toolCallId ) ;
194218
195219 if ( recordOutputs && observation . result !== undefined ) {
196220 span . setAttribute ( GEN_AI_TOOL_CALL_RESULT , stringify ( observation . result ) ) ;
@@ -206,7 +230,7 @@ export function endToolSpan(observation: FlueObservation, toolSpans: Map<string,
206230 * `turn_request` is the only event carrying the request's content — the settled `turn` reports
207231 * metadata alone — so input messages, system prompt and tool definitions are read from it.
208232 */
209- export function recordRequestContent ( observation : FlueObservation , turnSpans : Map < string , Span > ) : void {
233+ export function recordRequestContent ( observation : FlueObservation , turnSpans : SpanTracker ) : void {
210234 const { turnId } = observation ;
211235 const span = turnId ? turnSpans . get ( turnId ) : undefined ;
212236 const input = observation . request ?. input ;
0 commit comments