Skip to content

Commit f697f0c

Browse files
RulaKhaledclaude
andauthored
feat(server-utils): Add first-party Flue instrumentation (#24265)
Instruments the Flue agent framework (`@flue/runtime`) through its own `instrument()` hook, producing the `invoke_agent` → `chat` / `execute_tool` hierarchy with token usage, Flue-computed cost and message content. ```ts import { instrument } from '@flue/runtime'; import * as Sentry from '@sentry/node'; instrument(Sentry.createFlueInstrumentation()); ``` Verified against a scaffolded `flue init` app driven over HTTP against a real provider, on Node and Cloudflare: ``` http.server POST └─ gen_ai.invoke_agent invoke_agent Hello ├─ gen_ai.chat chat anthropic/claude-haiku-4.5 [988 in / 123 out / $0.001603] ├─ gen_ai.execute_tool execute_tool get_weather └─ gen_ai.chat chat anthropic/claude-haiku-4.5 [1035 in / 27 out / $0.00117] ``` _Root cause of the shape_: Flue is not instrumented at a call site — it exposes `instrument()`, a registration API whose registry is module-scope state. An auto-registering integration would need a reference to that module's own binding, and no channel payload carries one (`instrument` appears in Flue's build only as the function definition and in its `export {}` list, never as a property, argument or return value). Registration is therefore left to the user, which is also Flue's documented pattern for observability providers, and needs neither the runtime hook nor a bundler plugin. The two callbacks own different halves: the interceptor owns the agent span and the active context, so spans opened underneath parent correctly; `observe` owns the turn and tool spans, because `turn_start`/`turn` are the only signal one-to-one with a model call and `turn` is what carries usage. Spanning the `model` operation instead does not work — 12 fire per turn, and the first resolves long before usage is known. Flue reaches providers through `@earendil-works/pi-ai`, which bundles the `openai`, `@anthropic-ai/sdk` and `@google/genai` clients, so those are skipped while Flue is instrumented; the skip lives in `createFlueInstrumentation` so it applies however the instrumentation is registered. Turn and tool spans are tracked in `LRUMap`s rather than plain maps. Both are keyed off an id that only the matching end observation removes, and a stream abandoned mid-turn never emits one, so an uncapped map would grow for the lifetime of the process. Eviction ends the span it drops instead of letting it disappear unsent, which is the same trade Mastra's exporter makes. **On the Sentry bundler plugin**: not required, and not recommended for Flue's sake. Span trees are identical with and without it on both targets — HTTP spans come from Node's native `diagnostics_channel`, and the provider skip fires either way. It is worth adding only if the app also uses libraries that need orchestrion (pg, redis, kafka…); for a pure Flue app it force-bundles `@flue/runtime` (55KB → 4.3MB measured) for no telemetry gain. The export is added to every runtime that re-exports `@sentry/node`. `astro` and `elysia` need it named explicitly because they keep hand-maintained export lists (Vite puts a wildcard re-export under `default` in Astro prod builds); `nextjs`, `remix` and `sveltekit` use a real `export *` and pick it up on their own. Tests are stacked in #24266. Known gaps: Flue ships its own Sentry blueprint (`flue add tooling sentry`) targeting `@sentry/node@^10.64.0`, which our docs currently point at — needs a docs update, same shape as the `@mastra/sentry` migration. Fixes #24017 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent f97122f commit f697f0c

14 files changed

Lines changed: 693 additions & 1 deletion

File tree

‎packages/astro/src/index.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export {
9494
openAIIntegration,
9595
langChainIntegration,
9696
langGraphIntegration,
97+
createFlueInstrumentation,
9798
mastraIntegration,
9899
SentryMastraExporter,
99100
parameterize,

‎packages/aws-serverless/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export {
6464
langGraphIntegration,
6565
mastraIntegration,
6666
SentryMastraExporter,
67+
createFlueInstrumentation,
6768
modulesIntegration,
6869
nodeRuntimeMetricsIntegration,
6970
type NodeRuntimeMetricsOptions,

‎packages/bun/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export {
8686
langGraphIntegration,
8787
mastraIntegration,
8888
SentryMastraExporter,
89+
createFlueInstrumentation,
8990
modulesIntegration,
9091
contextLinesIntegration,
9192
nodeContextIntegration,

‎packages/cloudflare/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export {
107107
spanStreamingIntegration,
108108
} from '@sentry/core';
109109
export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server';
110-
export { instrumentPostgresJsSql } from '@sentry/server-utils';
110+
export { createFlueInstrumentation, instrumentPostgresJsSql } from '@sentry/server-utils';
111111

112112
export { withSentry } from './withSentry';
113113
export { defineCloudflareOptions } from './defineCloudflareOptions';

‎packages/deno/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export {
137137
langGraphIntegration,
138138
mastraIntegration,
139139
SentryMastraExporter,
140+
createFlueInstrumentation,
140141
lruMemoizerIntegration,
141142
mongoIntegration,
142143
mongooseIntegration,

‎packages/elysia/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export {
6363
openAIIntegration,
6464
langChainIntegration,
6565
langGraphIntegration,
66+
createFlueInstrumentation,
6667
mastraIntegration,
6768
SentryMastraExporter,
6869
modulesIntegration,

‎packages/google-cloud-serverless/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export {
6464
langGraphIntegration,
6565
mastraIntegration,
6666
SentryMastraExporter,
67+
createFlueInstrumentation,
6768
modulesIntegration,
6869
nodeRuntimeMetricsIntegration,
6970
type NodeRuntimeMetricsOptions,

‎packages/node/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export {
2020
langChainIntegration,
2121
langGraphIntegration,
2222
lruMemoizerIntegration,
23+
createFlueInstrumentation,
2324
mastraIntegration,
2425
SentryMastraExporter,
2526
mongoIntegration,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export const FLUE_ORIGIN = 'auto.ai.flue';
2+
3+
/**
4+
* Identifies our registration in Flue's keyed instrumentation registry.
5+
*
6+
* `key` is optional, but without one there is no protection against registering twice: Flue
7+
* deduplicates on object identity, and `createFlueInstrumentation()` returns a new object each
8+
* call, so a second `instrument()` would silently add a second observer and interceptor and
9+
* duplicate every span.
10+
*
11+
* With a key, Flue handles the repeat itself — it throws `InstrumentationAlreadyInstalledError` in
12+
* production, and in dev disposes the previous registration and swaps in the new one, which is what
13+
* stops `vite dev` from stacking observers across reloads.
14+
*/
15+
export const FLUE_INSTRUMENTATION_KEY = Symbol.for('sentry.flue.instrumentation');
16+
17+
/**
18+
* The Flue execution operations we act on.
19+
*
20+
* Only `AGENT` is spanned from the interceptor, and only at the outermost depth: Flue drives one
21+
* LLM call through many `MODEL` operations (one per stream read), and its `AGENT` operation nests
22+
* inside itself once per submission. The turn span is driven from the observation stream instead,
23+
* where `turn_start`/`turn` are exactly one-to-one with a model call. `MODEL` and `TOOL` are
24+
* intercepted only to make the already-open span active for the duration of the operation.
25+
*/
26+
export const FLUE_OPERATION = {
27+
AGENT: 'agent',
28+
MODEL: 'model',
29+
TOOL: 'tool',
30+
} as const;
31+
32+
/**
33+
* Cap on tracked turn and tool spans, matching `MAX_TRACKED_MASTRA_SPANS`. Both maps are keyed off
34+
* an id that is only removed when the matching end observation arrives; a stream that is abandoned
35+
* mid-turn never emits one, so without a cap the map grows for the lifetime of the process.
36+
*/
37+
export const MAX_TRACKED_FLUE_SPANS = 1000;
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import type { Span } from '@sentry/core';
2+
import {
3+
_INTERNAL_shouldSkipAiProviderWrapping,
4+
_INTERNAL_skipAiProviderWrapping,
5+
continueTrace,
6+
getActiveSpan,
7+
LRUMap,
8+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
9+
startSpan,
10+
withActiveSpan,
11+
} from '@sentry/core';
12+
import { GEN_AI_AGENT_NAME, GEN_AI_CONVERSATION_ID, GEN_AI_OPERATION_NAME } from '@sentry/conventions/attributes';
13+
import { ANTHROPIC_AI_INTEGRATION_NAME } from '../anthropic-ai/constants';
14+
import type { GenAiOptions } from '../core/utils';
15+
import { getGenAiSpanOp, resolveAIRecordingOptions } from '../core/utils';
16+
import { GOOGLE_GENAI_INTEGRATION_NAME } from '../google-genai/constants';
17+
import { OPENAI_INTEGRATION_NAME } from '../openai/constants';
18+
import { FLUE_INSTRUMENTATION_KEY, FLUE_OPERATION, FLUE_ORIGIN, MAX_TRACKED_FLUE_SPANS } from './constants';
19+
import type { SpanTracker } from './utils';
20+
import {
21+
endToolSpan,
22+
endTurnSpan,
23+
recordRequestContent,
24+
sentryTraceFromTraceparent,
25+
startToolSpan,
26+
startTurnSpan,
27+
} from './utils';
28+
import type { FlueInstrumentation } from './types';
29+
30+
export type FlueOptions = GenAiOptions;
31+
32+
const SKIPPED_PROVIDERS = [OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME];
33+
34+
/**
35+
* Build the object to hand to `instrument()` from `@flue/runtime`.
36+
*
37+
* The two callbacks own different halves of the result:
38+
*
39+
* - `interceptor` wraps agent execution, so the agent span is *active* for its duration and every
40+
* span opened underneath parents correctly.
41+
* - `observe` opens and closes the turn span, because Flue's `turn_start`/`turn` events are the
42+
* only one-to-one signal for a model call and `turn` is what carries usage and cost.
43+
*
44+
* Message content, tool arguments and tool results are gated on `recordInputs`/`recordOutputs`,
45+
* which fall back to the current client's `dataCollection.genAI` settings and are read per event.
46+
*/
47+
export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstrumentation {
48+
// Flue drives the providers through `@earendil-works/pi-ai`, which bundles the `openai`,
49+
// `@anthropic-ai/sdk` and `@google/genai` clients. Left alone they instrument the same call this
50+
// reports as a turn, emitting a second `gen_ai.chat` beside ours.
51+
//
52+
// Applied on first use rather than here, for two reasons. Constructing the object proves nothing
53+
// — if `instrument()` rejects it, suppressing the provider integrations would leave the app with
54+
// no `gen_ai.chat` spans at all. And the registry is reset per client (`_setupIntegrations`
55+
// clears it, and Cloudflare calls `init()` per request), so a one-shot call at module scope is
56+
// wiped by the next `init()` and every later request double-reports.
57+
const skipProviders = (): void => {
58+
if (!SKIPPED_PROVIDERS.every(provider => _INTERNAL_shouldSkipAiProviderWrapping(provider))) {
59+
_INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS);
60+
}
61+
};
62+
63+
// Keyed by the agent operation's own id, which is what the observations carry. That keeps
64+
// concurrent runs apart and gives a delegated subagent its own span: Flue nests a second `agent`
65+
// 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.
67+
const agentSpans = 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);
71+
72+
return {
73+
key: FLUE_INSTRUMENTATION_KEY,
74+
75+
interceptor: async (operation, ctx, next) => {
76+
skipProviders();
77+
78+
// `observe` has already opened the span for this unit of work; make it active for the
79+
// duration so whatever the tool or model call does lands inside it rather than beside it.
80+
if (operation?.type === FLUE_OPERATION.TOOL) {
81+
const toolSpan = operation.toolCallId ? toolSpans.get(operation.toolCallId) : undefined;
82+
return toolSpan ? withActiveSpan(toolSpan, next) : next();
83+
}
84+
if (operation?.type === FLUE_OPERATION.MODEL) {
85+
const turnSpan = operation.turnId ? turnSpans.get(operation.turnId) : undefined;
86+
return turnSpan ? withActiveSpan(turnSpan, next) : next();
87+
}
88+
89+
if (operation?.type !== FLUE_OPERATION.AGENT) {
90+
return next();
91+
}
92+
93+
// A submission opens with a wrapper operation whose id *is* the submission id; the run it
94+
// wraps gets its own operation id, and that is the one the observations reference. Spanning
95+
// the wrapper too would double-count every agent invocation.
96+
const operationId = operation.operationId;
97+
if (!operationId || operationId === ctx.submissionId || agentSpans.has(operationId)) {
98+
return next();
99+
}
100+
101+
const openAgentSpan = () =>
102+
startSpan(
103+
{
104+
name: `invoke_agent ${ctx.agentName ?? 'agent'}`,
105+
op: getGenAiSpanOp('invoke_agent'),
106+
attributes: {
107+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: FLUE_ORIGIN,
108+
[GEN_AI_OPERATION_NAME]: 'invoke_agent',
109+
...(ctx.agentName ? { [GEN_AI_AGENT_NAME]: ctx.agentName } : {}),
110+
...(ctx.conversationId ? { [GEN_AI_CONVERSATION_ID]: ctx.conversationId } : {}),
111+
},
112+
},
113+
async (span: Span) => {
114+
agentSpans.set(operationId, span);
115+
try {
116+
return await next();
117+
} finally {
118+
agentSpans.delete(operationId);
119+
}
120+
},
121+
);
122+
123+
// A durable or dispatched submission is run later by the coordinator, possibly in another
124+
// isolate or process, where nothing links it back to the request that enqueued it. Flue
125+
// replays that request's `traceparent` here, so continue from it — but only when no trace is
126+
// already active, so an in-process dispatch keeps the trace it is genuinely part of.
127+
const sentryTrace = ctx.traceCarrier?.traceparent
128+
? sentryTraceFromTraceparent(ctx.traceCarrier.traceparent)
129+
: undefined;
130+
131+
return sentryTrace && !getActiveSpan()
132+
? continueTrace({ sentryTrace, baggage: undefined }, openAgentSpan)
133+
: openAgentSpan();
134+
},
135+
136+
observe: observation => {
137+
skipProviders();
138+
139+
// Resolved per observation, not once at construction: `resolveAIRecordingOptions` reads the
140+
// current client's `dataCollection.genAI`, and Cloudflare replaces the client per request, so
141+
// values captured at isolate load would be the wrong ones for every later request.
142+
const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options);
143+
144+
// Observations are emitted synchronously from inside the agent operation, so the active span
145+
// here is the agent span the interceptor opened — turn and tool spans parent off it without
146+
// any bookkeeping.
147+
//
148+
// The agent operation that gets the span is the one the observations reference, and it knows
149+
// neither the agent's name nor the conversation: the submission wrapper carries the name, the
150+
// re-entry carries the conversation, and neither opens a span. Both arrive here instead.
151+
const agentSpan = observation.operationId ? agentSpans.get(observation.operationId) : undefined;
152+
if (agentSpan) {
153+
if (observation.conversationId) {
154+
agentSpan.setAttribute(GEN_AI_CONVERSATION_ID, observation.conversationId);
155+
}
156+
if (observation.agentName) {
157+
agentSpan.setAttribute(GEN_AI_AGENT_NAME, observation.agentName);
158+
agentSpan.updateName(`invoke_agent ${observation.agentName}`);
159+
}
160+
}
161+
162+
switch (observation.type) {
163+
case 'turn_start':
164+
startTurnSpan(observation, turnSpans);
165+
return;
166+
case 'turn_request':
167+
if (recordInputs) {
168+
recordRequestContent(observation, turnSpans);
169+
}
170+
return;
171+
case 'turn':
172+
endTurnSpan(observation, turnSpans, recordOutputs);
173+
return;
174+
case 'tool_start':
175+
startToolSpan(observation, toolSpans, recordInputs);
176+
return;
177+
case 'tool':
178+
endToolSpan(observation, toolSpans, recordOutputs);
179+
return;
180+
default:
181+
return;
182+
}
183+
},
184+
185+
// Only the turn and tool spans are ended here. They come from `startInactiveSpan`, so nothing
186+
// else will. The agent spans come from `startSpan`, which ends them when its callback settles —
187+
// ending them here would stamp an early end time on work that is still running, and the later
188+
// `end()` would be ignored.
189+
dispose: () => {
190+
for (const span of [...turnSpans.values(), ...toolSpans.values()]) {
191+
span.end();
192+
}
193+
agentSpans.clear();
194+
turnSpans.clear();
195+
toolSpans.clear();
196+
},
197+
};
198+
}

0 commit comments

Comments
 (0)