test(server-utils): Cover the Flue instrumentation - #24266
Conversation
size-limit report 📦
|
89239da to
deb3330
Compare
deb3330 to
bdda8e5
Compare
bdda8e5 to
612798a
Compare
74f4654 to
cd565ee
Compare
d69cb28 to
a7e44b7
Compare
965a470 to
ace67d3
Compare
ace67d3 to
da37aa7
Compare
da37aa7 to
354d0e9
Compare
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>
354d0e9 to
d2059a7
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d2059a7. Configure here.
| import { loggingTransport } from '@sentry-internal/node-integration-tests'; | ||
|
|
||
| Sentry.init({ | ||
| traceLifecycle: 'static', |
There was a problem hiding this comment.
l/q: Any reason static was used? We'd like to move over to stream tests instead
There was a problem hiding this comment.
no real reason, copied the shape from the other gen-ai suites
| await flue[Symbol.asyncDispose]?.(); | ||
| }); | ||
|
|
||
| await Sentry.flush(2000); |
There was a problem hiding this comment.
q: Is it required that users have to use Sentry.flush manually too when they use flue?
There was a problem hiding this comment.
nope, nothing flue specific. turn/tool spans end on their own observations and agent spans end when the startSpan callback settles.. it's there because the scenario is a script that exits the moment the run finishes
Unit tests over `createFlueInstrumentation` for the span shapes, the conversation id lifted off the re-entered agent operation, the usage/cost mapping, the all-zero-usage guard on failed turns, tool spans, content recording and its `recordInputs`/`recordOutputs` gating, and dispose. The integration test drives a real agent through a tool call using `pi-ai`'s `faux` provider, so the run is deterministic and needs no provider key or mock server. ESM only: `@flue/runtime` has no `require` export condition, and it is installed per-suite because its `engines.node >= 22.19` would break `yarn install` on the Node 20 CI matrix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Concurrent agent runs, subagent delegation (adopted from Isaac's repro, moved into the suite and asserted through a helper so a span-order assumption cannot creep back), the agent name arriving via the observations, trace continuation from the replayed traceparent, the provider skip applying on first use and re-applying after a registry reset, the recording options following the current client, and the conventional request attributes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both tests fail against the previous implementation: the first leaves `openai` registered before the run so the old first-entry-only guard short-circuits, and the second overflows the turn tracker to prove the evicted span is ended rather than dropped unsent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `model` and `tool` interceptor branches open no span; they make the span `observe` already opened active so the provider's HTTP call and the tool's own work nest inside it. Deleting both branches left all 28 tests green, and the e2e does not reach it either: its parent assertions come from the observation stream firing inside the agent operation, and nothing in that scenario opens a span inside a tool or model operation. Each case fails when its own branch is removed, and neither fails for the other's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both cases passed vacuously. `not.toBe(TRACE_ID)` on the malformed carrier also held when no agent span was opened at all, since `undefined` is not the carrier's id either; suppressing span creation entirely failed nine other tests and left that one green. And the active-trace case never checked the span landed on the surrounding request's trace, so continuing an unrelated new trace passed too. Now the first asserts a well-formed trace id, and the second asserts the agent span carries the `incoming request` span's own trace id. Dropping the `!getActiveSpan()` guard fails the second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fails without the capture: the span is still errored, so only the error event distinguishes the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Guards the concern raised on the Mastra error-capture PR: if a second capture path is added, or the error starts reaching the global handlers, this fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`toContain` plus `find` left the integration test blind to duplicate spans and to everything about the second turn. Count each span kind, assert both `chat` spans rather than whichever one `find` returned first, and tie the agent span to the surrounding transaction. Mutation-checked: dropping the submission wrapper guard, mis-parenting the second turn, and dropping its usage attributes each fail now; the last two passed before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tool declared no `input` schema, so Flue validated the model's arguments against an empty one and rejected them with "must not have additional properties". The tool body never ran. Nothing caught it: a rejected call still produces an `execute_tool` span with the right name and parent, and the suite only asserted those. Declare the schema, read the arguments from `ctx.data` where Flue passes them, and assert the span settles `ok` so the same silent failure can't come back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite inherited `traceLifecycle: 'static'` from the other gen-AI suites, where gen_ai spans reach the assertions by a detour: `extractGenAiSpansFromEvent` lifts them out of the transaction and re-emits them as a span container. Nothing in the Flue instrumentation depends on that, and streaming is what users get by default. On `stream` there is no transaction envelope — the `flue-test` root arrives in the same container as a segment span, so the root id comes from there instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
33ed5cf to
1cfa8dc
Compare
Stacked on #24266 — review that first, this is the e2e app only. The node-integration-test calls `__flueBindAgentModule` by hand, standing in for what `@flue/vite` does from the `'use agent'` directive, so it can't show whether a scaffolded app actually works. This is `flue init` output, built and served the way a user runs it, against a real provider. Covered, in both dev and prod and in both the base and orchestrion variants: - `invoke_agent` / `chat` / `execute_tool` with usage and cost - a thrown tool error becoming an issue - a manual span inside a tool nesting under `execute_tool` - an orchestrion-instrumented `dataloader` span landing in the agent's trace - the provider's `http.client` call nesting inside `chat` The loader runs inside a tool rather than a route so its span shares the agent's trace. No build externals are needed unlike `node-eve` — a Flue node build leaves dependencies as bare specifiers, so `dataloader` stays a real module for the transform to hook. `@flue/*` is pinned because the internal registry proxy 403s on releases it hasn't scanned. _Found_: a thrown tool error produced an errored span and no issue at all, since Flue catches the throw and hands it back to the model as a tool result. Fixed in #24265. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Stacked on #24265 — review that first, this is tests only.
Unit coverage for the instrumentation, plus an integration suite that drives an agent through a tool call and asserts the
invoke_agent→chat/execute_toolhierarchy — including that tool spans are siblings ofchatrather than children, matching how Flue's own OpenTelemetry adapter projects them.The scenario uses
pi-ai'sfauxprovider so responses are scripted in-process and no provider key is needed. It is ESM only (@flue/runtimehas norequireexport condition) and installed per-suite, sinceengines.node >= 22.19would breakyarn installon the Node 20 lane. Guarded byconditionalTest({ min: 22 }), so it skips on the repo's default Node 20.Each case was mutation-tested rather than just run green, which is what caught the tests that passed vacuously and an unreachable branch in
trackSpanthat has since been removed.