Skip to content

Commit d62f4f2

Browse files
RulaKhaledclaude
andcommitted
feat(server-utils): Record Flue message content behind the genAI options
Flue exposes the content on its event stream — `turn_request` carries the full `ModelRequest` (system prompt, messages, tool definitions), the settled `turn` carries `response.output`, and the tool events carry arguments and results — so there is no reason to omit it. Recorded as `gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.system_instructions`, `gen_ai.tool.definitions`, `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`, gated on `recordInputs`/`recordOutputs` via `resolveAIRecordingOptions`, which falls back to the client's `dataCollection.genAI` settings. `FlueOptions` is threaded back through `flueIntegration` and now does something. Note the request content is only on `turn_request`; the settled `turn` reports request metadata alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ed44ee3 commit d62f4f2

5 files changed

Lines changed: 94 additions & 18 deletions

File tree

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

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import {
55
SPAN_STATUS_ERROR,
66
startInactiveSpan,
77
startSpan,
8+
stringify,
89
withActiveSpan,
910
} from '@sentry/core';
1011
import {
1112
GEN_AI_AGENT_NAME,
1213
GEN_AI_CONVERSATION_ID,
14+
GEN_AI_INPUT_MESSAGES,
15+
GEN_AI_OUTPUT_MESSAGES,
1316
GEN_AI_COST_CACHE_CREATION_INPUT_TOKENS,
1417
GEN_AI_COST_CACHE_READ_INPUT_TOKENS,
1518
GEN_AI_COST_INPUT_TOKENS,
@@ -21,6 +24,10 @@ import {
2124
GEN_AI_RESPONSE_FINISH_REASONS,
2225
GEN_AI_RESPONSE_ID,
2326
GEN_AI_RESPONSE_MODEL,
27+
GEN_AI_SYSTEM_INSTRUCTIONS,
28+
GEN_AI_TOOL_CALL_ARGUMENTS,
29+
GEN_AI_TOOL_CALL_RESULT,
30+
GEN_AI_TOOL_DEFINITIONS,
2431
GEN_AI_TOOL_NAME,
2532
GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
2633
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
@@ -29,12 +36,15 @@ import {
2936
GEN_AI_USAGE_TOTAL_TOKENS,
3037
} from '@sentry/conventions/attributes';
3138
import { ANTHROPIC_AI_INTEGRATION_NAME } from '../anthropic-ai/constants';
32-
import { getGenAiSpanOp } from '../core/utils';
39+
import type { GenAiOptions } from '../core/utils';
40+
import { getGenAiSpanOp, resolveAIRecordingOptions } from '../core/utils';
3341
import { GOOGLE_GENAI_INTEGRATION_NAME } from '../google-genai/constants';
3442
import { OPENAI_INTEGRATION_NAME } from '../openai/constants';
3543
import { FLUE_INSTRUMENTATION_KEY, FLUE_ORIGIN, SPANNED_OPERATION_TYPE } from './constants';
3644
import type { FlueInstrumentation, FlueObservation, FlueUsage } from './types';
3745

46+
export type FlueOptions = GenAiOptions;
47+
3848
const SKIPPED_PROVIDERS = [OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME];
3949

4050
/**
@@ -47,17 +57,18 @@ const SKIPPED_PROVIDERS = [OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAM
4757
* - `observe` opens and closes the turn span, because Flue's `turn_start`/`turn` events are the
4858
* only one-to-one signal for a model call and `turn` is what carries usage and cost.
4959
*
50-
* Takes no options yet: no message content is recorded, so there is nothing for
51-
* `recordInputs`/`recordOutputs` to gate.
60+
* Message content, tool arguments and tool results are gated on `recordInputs`/`recordOutputs`,
61+
* which fall back to the client's `dataCollection.genAI` settings.
5262
*/
53-
export function createFlueInstrumentation(): FlueInstrumentation {
63+
export function createFlueInstrumentation(options: FlueOptions = {}): FlueInstrumentation {
5464
// Flue drives the providers through `@earendil-works/pi-ai`, which bundles the `openai`,
5565
// `@anthropic-ai/sdk` and `@google/genai` clients. Left alone they instrument the same call this
5666
// reports as a turn, emitting a second `gen_ai.chat` beside ours. Done here rather than in
5767
// `flueIntegration` so registering by hand — the only option on Cloudflare, where agents run in
5868
// per-Durable-Object isolates — gets it too.
5969
_INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS);
6070

71+
const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options);
6172
const turnSpans = new Map<string, Span>();
6273
const toolSpans = new Map<string, Span>();
6374
let agentSpan: Span | undefined;
@@ -113,14 +124,19 @@ export function createFlueInstrumentation(): FlueInstrumentation {
113124
case 'turn_start':
114125
startTurnSpan(observation, turnSpans, agentSpan);
115126
return;
127+
case 'turn_request':
128+
if (recordInputs) {
129+
recordRequestContent(observation, turnSpans);
130+
}
131+
return;
116132
case 'turn':
117-
endTurnSpan(observation, turnSpans);
133+
endTurnSpan(observation, turnSpans, recordOutputs);
118134
return;
119135
case 'tool_start':
120-
startToolSpan(observation, toolSpans, agentSpan);
136+
startToolSpan(observation, toolSpans, agentSpan, recordInputs);
121137
return;
122138
case 'tool':
123-
endToolSpan(observation, toolSpans);
139+
endToolSpan(observation, toolSpans, recordOutputs);
124140
return;
125141
default:
126142
return;
@@ -159,7 +175,7 @@ function startTurnSpan(observation: FlueObservation, turnSpans: Map<string, Span
159175
turnSpans.set(turnId, agentSpan ? withActiveSpan(agentSpan, open) : open());
160176
}
161177

162-
function endTurnSpan(observation: FlueObservation, turnSpans: Map<string, Span>): void {
178+
function endTurnSpan(observation: FlueObservation, turnSpans: Map<string, Span>, recordOutputs: boolean): void {
163179
const { turnId } = observation;
164180
const span = turnId ? turnSpans.get(turnId) : undefined;
165181
if (!span || !turnId) {
@@ -193,6 +209,11 @@ function endTurnSpan(observation: FlueObservation, turnSpans: Map<string, Span>)
193209
span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [finishReason]);
194210
}
195211

212+
const output = observation.response?.output;
213+
if (recordOutputs && output !== undefined) {
214+
span.setAttribute(GEN_AI_OUTPUT_MESSAGES, stringify(output));
215+
}
216+
196217
setUsageAttributes(span, observation.response?.usage, observation.isError);
197218

198219
if (observation.isError) {
@@ -239,7 +260,12 @@ function setUsageAttributes(span: Span, usage: FlueUsage | undefined, isError?:
239260
* OpenTelemetry adapter projects them: siblings of `chat`, correlated to model output by tool call
240261
* id. Keyed by `toolCallId` so concurrent tool calls in one turn cannot cross-attribute.
241262
*/
242-
function startToolSpan(observation: FlueObservation, toolSpans: Map<string, Span>, agentSpan: Span | undefined): void {
263+
function startToolSpan(
264+
observation: FlueObservation,
265+
toolSpans: Map<string, Span>,
266+
agentSpan: Span | undefined,
267+
recordInputs: boolean,
268+
): void {
243269
const { toolCallId, toolName } = observation;
244270
if (!toolCallId || toolSpans.has(toolCallId)) {
245271
return;
@@ -254,22 +280,52 @@ function startToolSpan(observation: FlueObservation, toolSpans: Map<string, Span
254280
[GEN_AI_OPERATION_NAME]: 'execute_tool',
255281
...(toolName ? { [GEN_AI_TOOL_NAME]: toolName } : {}),
256282
...(observation.conversationId ? { [GEN_AI_CONVERSATION_ID]: observation.conversationId } : {}),
283+
...(recordInputs && observation.args !== undefined
284+
? { [GEN_AI_TOOL_CALL_ARGUMENTS]: stringify(observation.args) }
285+
: {}),
257286
},
258287
});
259288

260289
toolSpans.set(toolCallId, agentSpan ? withActiveSpan(agentSpan, open) : open());
261290
}
262291

263-
function endToolSpan(observation: FlueObservation, toolSpans: Map<string, Span>): void {
292+
function endToolSpan(observation: FlueObservation, toolSpans: Map<string, Span>, recordOutputs: boolean): void {
264293
const { toolCallId } = observation;
265294
const span = toolCallId ? toolSpans.get(toolCallId) : undefined;
266295
if (!span || !toolCallId) {
267296
return;
268297
}
269298
toolSpans.delete(toolCallId);
270299

300+
if (recordOutputs && observation.result !== undefined) {
301+
span.setAttribute(GEN_AI_TOOL_CALL_RESULT, stringify(observation.result));
302+
}
303+
271304
if (observation.isError) {
272305
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
273306
}
274307
span.end();
275308
}
309+
310+
/**
311+
* `turn_request` is the only event carrying the request's content — the settled `turn` reports
312+
* metadata alone — so input messages, system prompt and tool definitions are read from it.
313+
*/
314+
function recordRequestContent(observation: FlueObservation, turnSpans: Map<string, Span>): void {
315+
const { turnId } = observation;
316+
const span = turnId ? turnSpans.get(turnId) : undefined;
317+
const input = observation.request?.input;
318+
if (!span || !input) {
319+
return;
320+
}
321+
322+
if (input.systemPrompt) {
323+
span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, input.systemPrompt);
324+
}
325+
if (input.messages) {
326+
span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(input.messages));
327+
}
328+
if (input.tools?.length) {
329+
span.setAttribute(GEN_AI_TOOL_DEFINITIONS, stringify(input.tools));
330+
}
331+
}

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,23 @@ export interface FlueModelRequestInfo {
2929
providerName?: string;
3030
}
3131

32+
/** Mirrors `ModelRequestInput` — the content half of `ModelRequest`, on `turn_request` only. */
33+
export interface FlueModelRequestInput {
34+
systemPrompt?: string;
35+
messages?: unknown[];
36+
tools?: unknown[];
37+
}
38+
39+
/** `turn_request` carries `ModelRequest`, which is `ModelRequestInfo` plus the input. */
40+
export interface FlueModelRequest extends FlueModelRequestInfo {
41+
input?: FlueModelRequestInput;
42+
}
43+
3244
/** Mirrors `ModelResponse`. */
3345
export interface FlueModelResponse {
3446
responseId?: string;
3547
responseModel?: string;
48+
output?: unknown;
3649
usage?: FlueUsage;
3750
finishReason?: string;
3851
}
@@ -53,7 +66,9 @@ export interface FlueObservation {
5366
isError?: boolean;
5467
purpose?: string;
5568
durationMs?: number;
56-
request?: FlueModelRequestInfo;
69+
request?: FlueModelRequest;
70+
args?: unknown;
71+
result?: unknown;
5772
response?: FlueModelResponse;
5873
}
5974

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from '.
1212
export { instrumentStateGraph, instrumentStateGraphCompile, instrumentCreateReactAgent } from './langgraph';
1313
export { SentryMastraExporter } from './mastra';
1414
export { createFlueInstrumentation } from './flue';
15+
export type { FlueOptions } from './flue';

packages/server-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export { knexIntegration } from './integrations/knex';
4141
export { langChainIntegration } from './integrations/langchain';
4242
export { langGraphIntegration } from './integrations/langgraph';
4343
export { flueIntegration } from './integrations/flue';
44+
export type { FlueOptions } from './integrations/flue';
4445
export { mastraIntegration } from './integrations/mastra';
4546
export { SentryMastraExporter } from './ai/mastra';
4647
export { lruMemoizerIntegration } from './integrations/lru-memoizer';

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { IntegrationFn } from '@sentry/core';
22
import { debug, defineIntegration, GLOBAL_OBJ } from '@sentry/core';
33
import { createFlueInstrumentation } from '../ai/flue';
44
import { FLUE_INTEGRATION_NAME, FLUE_MODULE_NAME } from '../ai/flue/constants';
5+
import type { FlueOptions } from '../ai/flue';
56
import type { FlueInstrumentation } from '../ai/flue/types';
67
import { DEBUG_BUILD } from '../debug-build';
78
import { flueModuleNames } from '../orchestrion/config/flue';
@@ -10,11 +11,11 @@ import { getOrchestrionModuleBindings } from '../utils/moduleInjected';
1011

1112
type FlueInstrumentFn = (instrumentation: FlueInstrumentation) => unknown;
1213

13-
const _flueIntegration = (() => {
14+
const _flueIntegration = ((options: FlueOptions = {}) => {
1415
return {
1516
name: FLUE_INTEGRATION_NAME,
1617
setup(client) {
17-
invokeOrchestrionInstrumentation(client, flueModuleNames, registerFlueInstrumentation, [], {
18+
invokeOrchestrionInstrumentation(client, flueModuleNames, registerFlueInstrumentation, [options], {
1819
// Nothing is bound to a tracing channel: the interceptor opens the agent span itself, so
1920
// the async-context binding is not a precondition for registering.
2021
requiresTracingChannelBinding: false,
@@ -29,10 +30,10 @@ const _flueIntegration = (() => {
2930
* own binding out. Under the runtime hook there is no snippet, but the resolved file is recorded,
3031
* and ESM keys its module registry by URL — so importing that URL yields the running namespace.
3132
*/
32-
function registerFlueInstrumentation(): void {
33+
function registerFlueInstrumentation(options: FlueOptions): void {
3334
const bound = getOrchestrionModuleBindings(FLUE_MODULE_NAME)?.instrument as FlueInstrumentFn | undefined;
3435
if (typeof bound === 'function') {
35-
install(bound);
36+
install(bound, options);
3637
return;
3738
}
3839

@@ -46,7 +47,7 @@ function registerFlueInstrumentation(): void {
4647
import(url).then(
4748
(mod: { instrument?: FlueInstrumentFn }) => {
4849
if (mod.instrument) {
49-
install(mod.instrument);
50+
install(mod.instrument, options);
5051
}
5152
},
5253
(error: unknown) => {
@@ -55,9 +56,9 @@ function registerFlueInstrumentation(): void {
5556
);
5657
}
5758

58-
function install(instrument: FlueInstrumentFn): void {
59+
function install(instrument: FlueInstrumentFn, options: FlueOptions): void {
5960
try {
60-
instrument(createFlueInstrumentation());
61+
instrument(createFlueInstrumentation(options));
6162
} catch (error) {
6263
// Flue throws `InstrumentationAlreadyInstalledError` if something already registered under our
6364
// key. The earlier registration is live, so log rather than surface it.
@@ -73,3 +74,5 @@ function install(instrument: FlueInstrumentFn): void {
7374
* `@flue/runtime` reachable, and neither path can instrument without one.
7475
*/
7576
export const flueIntegration = defineIntegration(_flueIntegration);
77+
78+
export type { FlueOptions };

0 commit comments

Comments
 (0)