Skip to content

feat(server-utils): Add first-party Flue instrumentation - #24265

Open
RulaKhaled wants to merge 4 commits into
developfrom
feat/flue-instrumentation-base
Open

feat(server-utils): Add first-party Flue instrumentation#24265
RulaKhaled wants to merge 4 commits into
developfrom
feat/flue-instrumentation-base

Conversation

@RulaKhaled

@RulaKhaled RulaKhaled commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Instruments the Flue agent framework (@flue/runtime) through its own instrument() hook, producing the invoke_agentchat / execute_tool hierarchy with token usage, Flue-computed cost and message content.

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 LRUMaps 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

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 28.96 kB - -
@sentry/browser - with treeshaking flags 27.26 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.15 kB - -
@sentry/browser (incl. Tracing) 50.48 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 50.48 kB - -
@sentry/browser (incl. Tracing, Profiling) 53.47 kB - -
@sentry/browser (incl. Tracing, Replay) 89.98 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.1 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 94.68 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 107.69 kB - -
@sentry/browser (incl. Feedback) 46.46 kB - -
@sentry/browser (incl. sendFeedback) 34.01 kB - -
@sentry/browser (incl. FeedbackAsync) 39.12 kB - -
@sentry/browser (incl. Metrics) 29.98 kB - -
@sentry/browser (incl. Logs) 30.24 kB - -
@sentry/browser (incl. Metrics & Logs) 30.91 kB - -
@sentry/react 30.72 kB - -
@sentry/react (incl. Tracing) 52.77 kB - -
@sentry/vue 36.2 kB - -
@sentry/vue (incl. Tracing) 52.71 kB - -
@sentry/svelte 28.98 kB - -
CDN Bundle 30.7 kB - -
CDN Bundle (incl. Tracing) 50.97 kB - -
CDN Bundle (incl. Logs, Metrics) 32.98 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 52.95 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73.67 kB - -
CDN Bundle (incl. Tracing, Replay) 88.52 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 90.48 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 94.59 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 96.59 kB - -
CDN Bundle - uncompressed 90.83 kB - -
CDN Bundle (incl. Tracing) - uncompressed 152.27 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.41 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 158.24 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 226.82 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 271.84 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 277.79 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 285.54 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 291.48 kB - -
@sentry/nextjs (client) 55.09 kB - -
@sentry/sveltekit (client) 50.88 kB - -
@sentry/core/server 37.13 kB - -
@sentry/core/browser 13.66 kB - -
@sentry/node 129.82 kB +0.02% +21 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 81.83 kB - -
@sentry/node - without tracing 89.62 kB +0.04% +32 B 🔺
@sentry/node - without channel injection 108.7 kB +0.02% +21 B 🔺
@sentry/aws-serverless 97.84 kB +0.03% +20 B 🔺
@sentry/cloudflare (withSentry) - minified 203.21 kB - -
@sentry/cloudflare (withSentry) 505.9 kB - -

View base workflow run

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is on a good path, but there are still some gaps. It surfaced an interesting shortcoming of @apm-js-collab/tracing-hooks that could save around some lines on this side. apm-js-collab/tracing-hooks#52

// A submission's agent operation re-enters once, and the two carry different halves of the
// agent's identity: the outer context names the agent, the inner one names the conversation.
// Only the outer becomes a span, so the conversation id is lifted onto it from the re-entry.
if (agentDepth++ > 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach is not parallel-safe.

agentDepth and agentSpan are single closure variables, and this treats any re-entry as a re-entry of the same submission. However, if an HTTP server or some other concurrency-heavy application had two parallel operations, those could clobber each other.

This can probably be fixed by using the operationId and submissionId to link the operations together. Keep agent spans in a Map keyed by operation.operationId, and resolve the parent in observe from observation.operationId. That also removes the depth counter and the re-entry special case entirely.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm not sure this is actually guaranteed to enter exactly twice? Looking at the flue code, the two dispatch sites I found are the coordinator and the session's runOperation (for operationKind of 'prompt' and 'skill').

runOperation is behind runExclusive, so it can't nest within one session. But subagent delegation runs in a separate session (DelegationDepthExceededError and defineSubagent are both exported), and a nested session's own runOperation('prompt') would be a third-level agent operation. Under the current code every subagent invocation is swallowed into the parent's single invoke_agent span.

I had the clanker clank up a test, written to packages/server-utils/test/ai/flue/nested-agent-operations.test.ts that seems to demonstrate this, if I'm understanding the behavior here properly: https://gist.github.com/isaacs/8f703ebae6adc26696b54c5d4f76b50e

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, confirmed and fixed, i added a test for this. One detail: the submission opens with a wrapper operation whose operationId is the submissionId, so that one is skipped or every invocation double-counts

}
},
);
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlueExecutionContext.traceCarrier is { traceparent, tracestate }, populated by extractTraceCarrier from the incoming request headers and passed on the outer agent operation.

But, the code here never reads it, so it seems like dispatched submissions will start orphan traces? For a durable or dispatched submission the coordinator runs the work later, possibly in another isolate, Durable Object, or process. Without traceCarrier, the invoke_agent span starts a brand new trace with no link to the request that enqueued it.

We could do this with a small helper, like:

function sentryTraceFromTraceparent(traceparent: string): string | undefined {
  const [version, traceId, spanId, flags] = traceparent.split('-');
  if (version !== '00' || !traceId || !spanId || !flags) {
    return undefined;
  }
  return `${traceId}-${spanId}-${parseInt(flags, 16) & 0x01 ? '1' : '0'}`;
}

(This could perhaps be reasonable to put in @sentry/core somewhere, near generateTraceparentHeader? I didn't see any w3c traceparent parser there already, and it's tiny, so we could also wait until there's a second use for it before abstracting.)

And then apply it only when nothing already continued the trace:

const sentryTrace = ctx.traceCarrier?.traceparent
  ? sentryTraceFromTraceparent(ctx.traceCarrier.traceparent)
  : undefined;

return sentryTrace && !getActiveSpan()
  ? continueTrace({ sentryTrace, baggage: undefined }, openAgentSpan)
  : openAgentSpan();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

implemented your helper and the !getActiveSpan() guard as written, plus three tests. it rarely fires though 😅 extractTraceCarrier reads only W3C traceparent and our propagateTraceparent is @default false, so sentry-to-sentry the carrier is undefined. flue says dispatch(...) doesn't propagate trace context at all. and on cloudflare the alarm invocation has an active span (invoke_agent's parent is the alarm span) so the guard skips it there (correctly i guess? since we set startNewTrace: true for alarms on purpose)

digging into it turned up something separate, storeSpanContext is keyed by method name and only runs for startNewTrace methods, so the chain is alarm → previous alarm and the request that scheduled it is never linked. that's a general DO gap rather than a flue one, filing it separately.

// reports as a turn, emitting a second `gen_ai.chat` beside ours. Done here rather than in
// `flueIntegration` so registering by hand — the only option on Cloudflare, where agents run in
// per-Durable-Object isolates — gets it too.
_INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls _INTERNAL_skipAiProviderWrapping once, when createFlueInstrumentation is constructed. On Cloudflare the documented usage is a manual call in module or Durable Object scope, so it runs once per isolate.

But, packages/cloudflare/src/client.ts line 188 calls _INTERNAL_clearAiProviderSkips() in _setupIntegrations(), and its comment states that Cloudflare calls init() per request. So the skip registered at isolate load is wiped by the first init() and never re-registered. Every request after the first in that isolate would double report gen_ai.chat for any provider client that is channel-instrumented, which is what the skip is there to prevent.

I think the skip needs to be re-applied per client, not once per instrumentation object. Registering it from an integration setup(client) would do that.

Also, the skip is a side effect of building the object, but the object is only useful once instrument() accepts it.

packages/server-utils/src/integrations/flue.ts lines 59-67 catch any throw from instrument() and log it.

So on the failure path the SDK has suppressed the other AI instrumentations and installed nothing in its place: the user gets no gen_ai.chat spans at all, only a debug log. Reading Flue's instrument function shows it throws InstrumentationAlreadyInstalledError when the key is taken and isDevMode() is false, and can also rethrow from registerExecutionInterceptor.

Recommendation: Move the skip to after a successful instrument() call, or restore the previous state on failure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dropped the integration for manual registration, so instead the skip is applied on first observe/interceptor call and re-applied if the registry has been cleared, that covers your second point too: constructing the object no longer suppresses anything, so a rejected instrument() can't leave the app with providers off and nothing installed

Comment thread packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts Outdated
Comment thread packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts Outdated
// per-Durable-Object isolates — gets it too.
_INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS);

const { recordInputs, recordOutputs } = resolveAIRecordingOptions(options);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other AI integration resolves these values at span time (see packages/server-utils/src/integrations/openai.ts line 82 and 124), because resolveAIRecordingOptions reads getClient()?.getDataCollectionOptions() (packages/server-utils/src/ai/core/utils.ts line 72).

On Cloudflare the client is replaced per request, so the values captured at isolate load are the wrong ones for every later request. On Node it happens to work because the client is stable. Resolving lazily would remove the divergence.

}

const open = (): Span =>
startInactiveSpan({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startToolSpan uses startInactiveSpan and parents off the agent span. Anything the tool does (database query, HTTP call, a nested call etc), then lands under invoke_agent as a sibling of execute_tool rather than inside it.

The interceptor already receives type: 'tool' with toolCallId and toolName, and type: 'model' with turnId. Wrapping next() in withActiveSpan(existingSpan, next) for those operation types would fix the nesting without creating extra spans, and would reuse the span the observe path already opened.

The comment at packages/server-utils/src/ai/flue/index.ts lines 173-174 says the
active span during observe "is whatever the provider SDK last opened". With the providers skipped, that is worth re-checking; if it no longer holds, the whole agentSpan plumbing could be replaced by the current active span.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice catch!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I fixed the tool spans structure (tool work lands beside execute_tool instead of inside it)
  2. took your fix: withActiveSpan(existingSpan, next) in the interceptor for tool and model operations, reusing the spans observe already opened, no extra spans. db.query now nests under execute_tool, and the model half also fixes http.client sitting beside gen_ai.chat.
  3. explicit parenting is gone (the agentSpans map stays though, text_delta and message_start also carry conversationId and fire during a model op where the active span is chat, so using the active span for attribution would stamp it on the wrong span. parenting implicit, attribution still explicit)

Comment thread packages/server-utils/src/integrations/flue.ts Outdated
Comment thread packages/server-utils/src/orchestrion/config/flue.ts Outdated
span.setAttribute(GEN_AI_RESPONSE_MODEL, responseModel);
}

const provider = observation.request?.providerId ?? observation.request?.providerName;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fields are required strings on ModelRequestInfo, so the fallback seems like it's not doing any work? I think just providerId is the correct value here.

Instruments the Flue agent framework (`@flue/runtime`) through the runtime's own
`instrument()` hook, producing the `invoke_agent` -> `chat` / `execute_tool`
hierarchy with token usage, Flue-computed cost and message content:

    import { instrument } from '@flue/runtime';
    import * as Sentry from '@sentry/node';

    instrument(Sentry.createFlueInstrumentation());

Registration is left to the user rather than done through orchestrion. Flue is
not instrumented at a call site — `instrument()` is a registration API whose
registry is module-scope state, so an auto-registering integration would need a
reference to that module's binding, which no channel payload carries. Flue
documents this same pattern for observability providers, and it 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` carries usage.

Also skips the raw provider integrations: Flue reaches providers through
`@earendil-works/pi-ai`, which bundles the `openai`, `@anthropic-ai/sdk` and
`@google/genai` clients, so those would emit a second `gen_ai.chat` beside ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RulaKhaled
RulaKhaled force-pushed the feat/flue-instrumentation-base branch from d62f4f2 to a0e8143 Compare September 11, 2026 06:13
- Key agent spans by `operation.operationId` instead of shared closure state, so
  concurrent runs cannot clobber each other and a delegated subagent gets its own
  span rather than being folded into its parent's. Removes the depth counter and
  the re-entry special case.
- Take the agent name and conversation id from the observations: the operation
  that gets the span carries neither, since the submission wrapper holds the name
  and the re-entry holds the conversation, and neither opens a span.
- Continue the trace from `ctx.traceCarrier` when a durable submission resumes
  with no active trace, so it links back to the request that enqueued it.
- Apply the AI provider skip on first use rather than at construction, and
  re-apply it once the registry has been cleared. Constructing the object no
  longer suppresses the provider integrations, so a rejected `instrument()`
  cannot leave an app with no `gen_ai.chat` spans at all.
- Make the turn and tool spans active for their operations, so a tool's own work
  and the provider's HTTP call nest inside them instead of beside them.
- Resolve the recording options per event rather than once, since the client is
  replaced per request on Cloudflare.
- Record the conventional request attributes (`temperature`, `max_tokens`,
  `reasoning.level`, `server.address`, `server.port`) and the turn purpose, and
  drop the dead `providerId ?? providerName` fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RulaKhaled

RulaKhaled commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

I think this is on a good path, but there are still some gaps. It surfaced an interesting shortcoming of @apm-js-collab/tracing-hooks that could save around some lines on this side. apm-js-collab/tracing-hooks#52

thanks for reviewing Isaac! this was still a WIP when you looked at it, hence the draft. i discussed it with Francesco and we landed on manual instrumentation rather than the module bindings, so anything related to that is moot now, i closed comments related to that change — the four shared-infra files are back to develop untouched and the orchestrion config and integration are gone. users call instrument(Sentry.createFlueInstrumentation()) themselves, same shape as eveConversationHook() and flue's own documented pattern.

RulaKhaled and others added 2 commits September 11, 2026 13:45
… 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>
Astro keeps a hand-maintained list of the `@sentry/node` re-exports, because
Vite puts a wildcard re-export under `default` in prod builds. The list missed
`createFlueInstrumentation`, which fails the `node-exports-test-app` check that
compares every dependent against `@sentry/node`. Nextjs, remix and sveltekit
use a real `export *` and pick it up on their own.

Elysia has the same hand-maintained shape and the same gap. Nothing covers it
in CI, but it sits next to `mastraIntegration` either way.

Also drops `FLUE_INTEGRATION_NAME` and `FLUE_MODULE_NAME`. Both are left over
from the module-binding approach and are referenced nowhere now that
registration is the user's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RulaKhaled
RulaKhaled marked this pull request as ready for review September 11, 2026 13:10
@RulaKhaled
RulaKhaled requested review from a team as code owners September 11, 2026 13:10
@RulaKhaled
RulaKhaled requested review from JPeer264, isaacs, mydea and nicohrubec and removed request for a team September 11, 2026 13:10

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0c24d2b. Configure here.

}
if (finishReason) {
span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [finishReason]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finish reasons stored as array

Medium Severity

gen_ai.response.finish_reasons is set as a raw string array. Every other AI integration in this package stores a JSON string, and existing integration tests assert a string value like ["stop"]. The AI Agents view and any consumer that parses this field as a string will not read Flue finish reasons correctly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0c24d2b. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add first-party Flue instrumentation

2 participants