Skip to content

Commit 41b32cc

Browse files
RulaKhaledclaude
andcommitted
fix(server-utils): Cap Flue span tracking and widen the provider skip guard
The turn and tool maps are keyed off ids that only a matching end observation removes, so a stream abandoned mid-turn left an entry behind for the lifetime of the process. Both are now `LRUMap`s capped the same way Mastra caps its own tracker, and eviction ends the span it drops rather than letting it disappear unsent. The provider skip guard only tested the first entry of `SKIPPED_PROVIDERS`, so an unrelated integration registering a skip for `openai` first would suppress the call that registers the other two, and their spans would duplicate the turn span. It now requires every provider to be registered before it short-circuits. Also names the Flue operation types we branch on, instead of one named constant for `agent` beside inline literals for `model` and `tool`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c01cb3f commit 41b32cc

3 files changed

Lines changed: 67 additions & 25 deletions

File tree

packages/server-utils/src/ai/flue/constants.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,23 @@ export const FLUE_ORIGIN = 'auto.ai.flue';
1212
export const FLUE_INSTRUMENTATION_KEY = Symbol.for('sentry.flue.instrumentation');
1313

1414
/**
15-
* Flue drives one LLM call through many `model` operations (one per stream read), and its `agent`
16-
* operation nests inside itself once per submission. Only `agent` is spanned from the interceptor,
17-
* and only at the outermost depth; the turn span is driven from the observation stream instead,
18-
* where `turn_start`/`turn` are exactly one-to-one with a model call.
15+
* The Flue execution operations we act on.
16+
*
17+
* Only `AGENT` is spanned from the interceptor, and only at the outermost depth: Flue drives one
18+
* LLM call through many `MODEL` operations (one per stream read), and its `AGENT` operation nests
19+
* inside itself once per submission. The turn span is driven from the observation stream instead,
20+
* where `turn_start`/`turn` are exactly one-to-one with a model call. `MODEL` and `TOOL` are
21+
* intercepted only to make the already-open span active for the duration of the operation.
1922
*/
20-
export const SPANNED_OPERATION_TYPE = 'agent';
23+
export const FLUE_OPERATION = {
24+
AGENT: 'agent',
25+
MODEL: 'model',
26+
TOOL: 'tool',
27+
} as const;
28+
29+
/**
30+
* Cap on tracked turn and tool spans, matching `MAX_TRACKED_MASTRA_SPANS`. Both maps are keyed off
31+
* an id that is only removed when the matching end observation arrives; a stream that is abandoned
32+
* mid-turn never emits one, so without a cap the map grows for the lifetime of the process.
33+
*/
34+
export const MAX_TRACKED_FLUE_SPANS = 1000;

packages/server-utils/src/ai/flue/index.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
_INTERNAL_skipAiProviderWrapping,
55
continueTrace,
66
getActiveSpan,
7+
LRUMap,
78
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
89
startSpan,
910
withActiveSpan,
@@ -14,7 +15,8 @@ import type { GenAiOptions } from '../core/utils';
1415
import { getGenAiSpanOp, resolveAIRecordingOptions } from '../core/utils';
1516
import { GOOGLE_GENAI_INTEGRATION_NAME } from '../google-genai/constants';
1617
import { OPENAI_INTEGRATION_NAME } from '../openai/constants';
17-
import { FLUE_INSTRUMENTATION_KEY, FLUE_ORIGIN, SPANNED_OPERATION_TYPE } from './constants';
18+
import { FLUE_INSTRUMENTATION_KEY, FLUE_OPERATION, FLUE_ORIGIN, MAX_TRACKED_FLUE_SPANS } from './constants';
19+
import type { SpanTracker } from './utils';
1820
import {
1921
endToolSpan,
2022
endTurnSpan,
@@ -53,17 +55,19 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru
5355
// clears it, and Cloudflare calls `init()` per request), so a one-shot call at module scope is
5456
// wiped by the next `init()` and every later request double-reports.
5557
const skipProviders = (): void => {
56-
if (!_INTERNAL_shouldSkipAiProviderWrapping(SKIPPED_PROVIDERS[0]!)) {
58+
if (!SKIPPED_PROVIDERS.every(provider => _INTERNAL_shouldSkipAiProviderWrapping(provider))) {
5759
_INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS);
5860
}
5961
};
6062

6163
// Keyed by the agent operation's own id, which is what the observations carry. That keeps
6264
// concurrent runs apart and gives a delegated subagent its own span: Flue nests a second `agent`
6365
// operation inside the parent's for `task` delegation, and the nesting is not bounded at two.
66+
// A plain map: the entry is removed in a `finally`, so it is bounded by concurrent agent runs.
6467
const agentSpans = new Map<string, Span>();
65-
const turnSpans = new Map<string, Span>();
66-
const toolSpans = new Map<string, Span>();
68+
// Capped, unlike the above: these are keyed off ids that only a matching end observation removes.
69+
const turnSpans: SpanTracker = new LRUMap(MAX_TRACKED_FLUE_SPANS);
70+
const toolSpans: SpanTracker = new LRUMap(MAX_TRACKED_FLUE_SPANS);
6771

6872
return {
6973
key: FLUE_INSTRUMENTATION_KEY,
@@ -73,16 +77,16 @@ export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstru
7377

7478
// `observe` has already opened the span for this unit of work; make it active for the
7579
// duration so whatever the tool or model call does lands inside it rather than beside it.
76-
if (operation?.type === 'tool') {
80+
if (operation?.type === FLUE_OPERATION.TOOL) {
7781
const toolSpan = operation.toolCallId ? toolSpans.get(operation.toolCallId) : undefined;
7882
return toolSpan ? withActiveSpan(toolSpan, next) : next();
7983
}
80-
if (operation?.type === 'model') {
84+
if (operation?.type === FLUE_OPERATION.MODEL) {
8185
const turnSpan = operation.turnId ? turnSpans.get(operation.turnId) : undefined;
8286
return turnSpan ? withActiveSpan(turnSpan, next) : next();
8387
}
8488

85-
if (operation?.type !== SPANNED_OPERATION_TYPE) {
89+
if (operation?.type !== FLUE_OPERATION.AGENT) {
8690
return next();
8791
}
8892

packages/server-utils/src/ai/flue/utils.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Span } from '@sentry/core';
1+
import type { LRUMap, Span } from '@sentry/core';
22
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan, stringify } from '@sentry/core';
33
import {
44
GEN_AI_CONVERSATION_ID,
@@ -32,7 +32,7 @@ import {
3232
SERVER_PORT,
3333
} from '@sentry/conventions/attributes';
3434
import { getGenAiSpanOp } from '../core/utils';
35-
import { FLUE_ORIGIN } from './constants';
35+
import { FLUE_ORIGIN, MAX_TRACKED_FLUE_SPANS } from './constants';
3636
import 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

Comments
 (0)