Repository navigation
feat(examples): TanStack AI otelMiddleware recipe (TH-8238) - #212
Merged
Merged
Conversation
The Node OTLP exporter streams with Transfer-Encoding: chunked and sends no Content-Length. The Receiver read Content-Length only, so every Node export came back 400 with zero spans; two TH-8103 children had to put a de-chunking relay in front of it. Receiver now accepts both framings. It also records one entry per accepted export (path, lower-cased headers, flattened resource attributes) via requests(), so a contract test can assert X-Api-Key/X-Secret-Key and project_name through the shared harness instead of a private recorder. Verified: 6 harness tests pass, and the TanStack example's real Node exporter delivered 4 chunked exports (4 spans, collector path, both auth headers, project_name/project_type) with no relay. Refs: TH-8339, TH-8103
No @traceai/tanstack-ai package. The example registers a Future AGI tracer provider with @traceai/fi-core register() (OTLP/HTTP to /tracer/v1/traces, X-Api-Key and X-Secret-Key, project_name and project_type=observe), passes its tracer to TanStack AI's otelMiddleware from @tanstack/ai/middlewares/otel, and leaves captureContent at its default of false. Span kinds are set from the middleware's span scope: model call LLM, tool TOOL, multi-call root AGENT. The route flushes in finally and an unreachable collector never fails chat(). @tanstack/ai 0.64.0 and @tanstack/ai-openai 0.26.0 are pinned as dependencies of the example only. It has its own npm install and is outside the pnpm workspace, so the root lockfile is unchanged. The contract test runs the example with node against a loopback fake OpenAI server and the shared harness Receiver. It asserts model, usage, span kinds and nesting, the absence of prompt, completion and tool content (with a capture-on control), auth headers and project resource attributes, a refused collector port, and abort. A recording relay de-chunks the exporter's chunked body, which the Receiver cannot read. Refs: TH-8238
otelMiddleware sums every model call's usage onto the chat root (@tanstack/ai 0.64.0 otel.ts applyRootUsage, set just before onSpanEnd). fi-collector promotes gen_ai.usage.* into the token columns on any span (adapter.go) and Observe sums total_tokens over a trace, so totals doubled. The recipe's onSpanEnd now moves the root's sum to tanstack.ai.root_usage.* when the model-call spans carry usage, and leaves it when they do not (then the root is the only copy). Test first: the contract test now asserts the summed promoted input tokens equal the two model calls. 7 passed. approved: Nikhil 2026-10-03 blanket Refs: TH-8238
The route called chat({ stream: false }). At @tanstack/ai 0.64.0 that
collects text with streamToText, which throws at the first RUN_ERROR
and stops reading (stream-to-response.ts). The engine's finally only
fires onAbort when the run was cancelled, so onError never ran and
otelMiddleware never ended the chat or model-call span. A model HTTP
500 failed the request and exported nothing.
answer() now reads chat()'s stream to the end, keeps the first
RUN_ERROR, and throws its message after the stream ends. The CLI
prints "chat failed: <message>" and exits 1, so the caller still gets
an error response.
Test first: the fake OpenAI server gains fail_status=500. The new
contract test failed with no export at all, and now sees the root and
model-call spans exported with status ERROR and message "500 boom".
approved: Nikhil 2026-10-03 blanket
Refs: TH-8238
applyRootUsage (@tanstack/ai 0.64.0 otel.ts) sums every gen_ai.usage.* key onto the root: cost, cache read/creation and reasoning tokens as well as input/output/total. The recipe moved only the three token keys, so cache, reasoning and cost were still counted twice per trace. When the model-call spans carry usage, every gen_ai.usage.<suffix> key on the root now moves to tanstack.ai.root_usage.<suffix> (full suffix, so cache_read.input_tokens and reasoning.output_tokens do not collide) and every gen_ai.cost.<suffix> key to tanstack.ai.root_usage.cost.<suffix>. Test first: the contract test asserts no gen_ai.usage.*/gen_ai.cost.* key on the root and trace sums equal to the per-call sums for input, output, total, cache_read and reasoning tokens; it failed on the root's cache_read and reasoning keys. New tests/span_kinds.test.mjs (node --test, run from pytest) drives futureAgiSpanKinds() with fake spans: no model call reported usage (root keeps it; kills reviewer mutation M2), one model call (no kind, nothing promoted on the root, trace sum equals the call), two calls with cost and cache keys (failed before this change). approved: Nikhil 2026-10-03 blanket Refs: TH-8238
moveRootUsage deletes keys from the SDK span's attributes object, which is not public API. It now names that dependency and the tested version (@opentelemetry/sdk-trace 2.11.0 SpanImpl), deletes before it sets, and package.json "overrides" pins @opentelemetry/sdk-trace-node 2.11.0, which pins sdk-trace-base and sdk-trace 2.11.0 exactly (fi-core asks for ^2.0.1). npm ls shows [email protected] overridden. Delete-then-set does not by itself save the copy under a tight attributeCountLimit: delete does not lower SpanImpl's _attributesCount, so the tanstack.ai.root_usage.* copy can still be dropped. The comment and README say so. The promoted keys always leave the root, so usage is never counted twice. Tests (tests/span_kinds.test.mjs): the sdk-trace-node, sdk-trace-base and sdk-trace versions fi-core resolves are 2.11.0; the move reaches a span exported by that SDK; with attributeCountLimit 5 no promoted key stays on the root and per-call input and output sums still hold. These guard existing behaviour, so they passed before the change; with the delete removed (mutation) four of six unit tests fail. approved: Nikhil 2026-10-03 blanket Refs: TH-8238
The architecture says the recipe shows the traceAI session helper, but
@traceai/fi-core setSession() only sets an OpenTelemetry context value.
otelMiddleware never reads it, and register()'s provider hands out the
plain SDK tracer, so no session reached the spans.
futureAgiOtelMiddleware(tracer, { threadIdAsSession: true }) now adds
session.id = ctx.threadId (ChatMiddlewareContext.threadId, @tanstack/ai
0.64.0 middleware/types.ts:207) to every span from attributeEnricher.
It is opt-in because chat() generates thread-<ms>-<random> when the
caller passes no threadId, which would make each request a session.
chatRoute and answer accept { threadId }; the CLI takes it as argv[3].
The README documents this as a deviation from the spec's helper.
Test first: the contract run now passes a thread id and asserts
session.id on all four spans (KeyError before), and the failed-call run
(no thread id) asserts none. Unit tests cover the enricher with and
without threadIdAsSession, including tool spans.
approved: Nikhil 2026-10-03 blanket
Refs: TH-8238
flushTraces() awaited forceFlush() with no bound. A collector that
accepts the connection and never answers held the route until the
OTLP/HTTP exporter's 10 s timeout (otlp-exporter-base 0.202.0).
flushTraces(tracerProvider, { timeoutMs = FLUSH_TIMEOUT_MS = 2000 })
races the flush against an unref'd timer, logs "[futureagi] span export
still pending after 2000 ms; not waiting", lets the export finish in the
background, and still never throws. The README and the doc comment say
the per-request flush is for serverless routes and that a long-lived
server should not await a flush on the request path (the
SimpleSpanProcessor already exports each span as it ends) and should
call shutdownTraces() at exit.
Test first: tests/timed_route.mjs prints the route's duration, and the
new contract test points the exporter at a socket that accepts and never
replies. It failed with routeMs 13504 and now passes (routeMs 2157 in a
manual run). The refused-port test still passes.
approved: Nikhil 2026-10-03 blanket
Refs: TH-8238
…sted
The README's "The recipe" snippet passed a bare otelMiddleware to chat():
no span kinds and no root usage move, so Future AGI would count every
model call's usage twice. It also told the reader to await
tracerProvider.forceFlush() in the route's finally, which rejects when
the collector is down and then replaces the route's answer, and can
wait for the exporter's 10 s timeout.
The snippet is now a complete route on futureAgiOtelMiddleware (span
kinds, usage move, optional session.id from threadId) and flushTraces
(bounded at 2 s, never throws), and it drains chat()'s stream instead of
using the non-streaming mode, so a failed model call still exports. The
README lists the new test fixtures.
Test first: test_readme_recipe_uses_the_recipe_middleware_and_bounded_flush
checks the snippet uses futureAgiOtelMiddleware and flushTraces, has no
bare otelMiddleware({, forceFlush or stream: false, and imports only
names src/tracing.mjs exports. test_readme_recipe_runs_and_exports_the_contract_spans
runs the snippet as written against the fake model and the Receiver
(one model call: LLM kind, no kind and no promoted usage on the root,
session.id on both spans, no prompt text). On the old README both
failed: the first on 'futureAgiOtelMiddleware(' not in the snippet, the
second with "ReferenceError: chatRoute is not defined".
approved: Nikhil 2026-10-03 blanket
Refs: TH-8238
…tput Review asked for proof that FI_API_KEY, FI_SECRET_KEY and OPENAI_API_KEY never reach an exported span, stdout or stderr. The contract test now checks every run for the three placeholder keys: the shipped route, the failed model call (which prints the provider error), the refused and the silent collector (which print exporter errors) and the README snippet. A positive control first shows the keys were in use: x-secret-key on every export and "Bearer placeholder-openai-key" on every model call (the fake now records Authorization; the fixture keeps it). The recipe already keeps the keys out, so these pass on c6001eb by design. Mutations show they are load-bearing, each reverted with git checkout: the enricher copying FI_SECRET_KEY onto spans fails 2 tests ('placeholder-fi-secret-key', 'spans'), and the CLI error line printing OPENAI_API_KEY fails test_failed_model_call_exports_error_spans ('placeholder-openai-key', 'stderr'). approved: Nikhil 2026-10-03 blanket Refs: TH-8238
…ider Installed review N6: registerFutureAgiTracing() called register() with its default setGlobalTracerProvider: true, so the Future AGI provider took the process's global OpenTelemetry slot. The recipe never uses the global: it passes tracerProvider.getTracer() to otelMiddleware. In an app with its own OpenTelemetry setup that either fails to register the app's provider or sends the app's other spans to Future AGI, and removing the recipe takes more than dropping the middleware. register() now gets setGlobalTracerProvider: false (an option of @traceai/fi-core 1.0.0). The exported spans are unchanged: the contract run still exports the same 4 spans. Test first: span_kinds.test.mjs "registerFutureAgiTracing() leaves the global tracer provider alone" registers with placeholder settings and asserts the global proxy's delegate is not the returned provider and that trace.setGlobalTracerProvider() still succeeds for the app's own provider. Before the change it failed: "Expected "actual" not to be reference-equal to "expected"" (node --test 8 pass, 1 fail). approved: Nikhil 2026-10-03 blanket Refs: TH-8238
Installed review N3 (PRD J3 / AC-05): neither the README route nor
src/chat.mjs told chat() when the client went away. TanStack ends the
otelMiddleware spans only when the run finishes, fails or is aborted
through chat()'s own abortController (@tanstack/ai 0.64.0
activities/chat/index.ts 1470-1480, isCancelled at 3732-3734). A route
that stops reading, or a server whose client disconnects without that
controller aborting, leaves every span open and exports nothing.
Both routes now take the request's abort signal (`signal`: request.signal
in a Fetch API handler, or a controller aborted on res.on("close") in
node:http) and pass chat() an AbortController that the signal aborts. The
README says never to leave the stream without aborting that controller,
and to pass the same controller to toServerSentEventsResponse when
streaming to the client. answer() in src/chat.mjs takes the controller.
Test first: test_client_disconnect_mid_stream_ends_and_exports_every_span
[readme] and [chat.mjs]. tests/disconnect_route.mjs serves one HTTP
request with the route and aborts the signal when the connection closes;
the test sends a request, closes the socket once the stalled fake model
has sent its first chunks (FakeOpenAI.stalled), and then expects the
server to exit within 6 s with ROOT and ITERATION_0 exported, both ERROR
"cancelled" with tanstack.ai.completion.reason=cancelled, and no
placeholder key in spans or output. Before the change both failed:
"route still running 6 s after the client disconnected; exported spans:
[]" (2 failed). After: 2 passed; full contract suite 15 passed and
node --test 9/9 on Node 26.8.1.
approved: Nikhil 2026-10-03 blanket
Refs: TH-8238
Installed review N4: package.json allowed Node ">=20", but the pinned @opentelemetry/sdk-trace-node, sdk-trace-base and sdk-trace 2.11.0 declare "^18.19.0 || >=20.6.0", so Node 20.0 to 20.5 was outside their support. The README also claimed "Node 20, 22 and 26" without saying which versions ran. engines.node is now ">=20.6.0". No other installed package needs more on the Node 20 line (the highest other floor is @tanstack/ai's ">=18"). The README states the floor and lists the Node versions the suites ran on: 20.20.2, 22.23.3 and 26.8.1. Test first: span_kinds.test.mjs "package.json engines starts at a Node version the pinned SDK supports" reads the example's engines floor and checks it against the engines range of each package in fi-core's SDK chain. Before the change it failed: "engines.node >=20 allows Node 20.0.0, outside @opentelemetry/sdk-trace-node's ^18.19.0 || >=20.6.0" (node --test 9 pass, 1 fail). After: 10/10 on Node 20.20.2, 22.23.3 and 26.8.1; contract suite 15 passed on each. approved: Nikhil 2026-10-03 blanket Refs: TH-8238
…captureContent Installed review N7: the README's privacy line said no prompt, completion, system prompt or tool content reaches a span because captureContent defaults to false. That holds for content, but on a failure otelMiddleware sets the error's message as the span status and records an exception event with message and stack trace, whatever captureContent is (@tanstack/ai 0.64.0 middlewares/otel.ts 980-985 for tools, 1061-1065 and 1100-1104 for model calls). That text comes from the provider or the tool, so a provider error that repeats request content reaches Future AGI. The README Privacy section now says so. test_failed_model_call_exports_error_spans pins the behaviour the README describes: ROOT and ITERATION_0 each carry an exception event whose exception.message contains the provider's "boom" and that has a stack trace, and the prompt, city and system prompt markers are absent from the error-path spans. It passes on the previous head by design: it pins existing TanStack behaviour so an upgrade that changes it is noticed. approved: Nikhil 2026-10-03 blanket Refs: TH-8238
Installed review N1: the README told readers to copy src/tracing.mjs, but the @opentelemetry/sdk-trace-node 2.11.0 pin the root usage move was tested on lives only in this example's package.json overrides. A copied recipe resolves the SDK through @traceai/fi-core 1.0.0's ^2.0.1 range. If an SDK version stopped exposing the span's plain attributes object, the move (tracing.mjs moveRootUsage and the usage check in onSpanEnd) would do nothing and every model call's usage would be counted twice, with no warning. "The recipe" now says the pin does not travel with the file, to copy the overrides entry too (npm overrides; pnpm.overrides or resolutions for pnpm and Yarn), and what goes wrong without it. Documentation only; the README snippet is unchanged and its two tests still pass. approved: Nikhil 2026-10-03 blanket Refs: TH-8238
Installed review N5: with threadIdAsSession the caller's thread id is copied verbatim to session.id on every span (tracing.mjs attributeEnricher) and so leaves the process, but the README did not say what the id should be. chat() also takes the legacy conversationId as the thread id when threadId is unset (@tanstack/ai 0.64.0 activities/chat/index.ts 1029-1035), so that value becomes the session too. The README Session section and the futureAgiSpanKinds doc comment now say to use an opaque id (a random UUID per conversation), never an email address or user name, and that the same applies to conversationId. Documentation only (a comment in src/tracing.mjs, no code change). approved: Nikhil 2026-10-03 blanket Refs: TH-8238
Installed review N2: the README gave the 2 s flush bound but not its
consequences.
- Serverless: flushTraces() stops waiting at the bound; it does not stop
the export (tracing.mjs flushTraces leaves `flushed` running). Spans
still pending then are lost if the runtime freezes or exits after the
response. The `timeoutMs` option was not documented.
- Long-lived servers: register()'s default SimpleSpanProcessor
(@traceai/fi-core 1.0.0 otel.js 356-358) exports each span in its own
OTLP request. The OTLP/HTTP exporter (otlp-exporter-base 0.202.0
shared-configuration.js 54-55) allows 30 exports in flight with a 10 s
timeout each and rejects the rest with "Concurrent export limit
reached" (otlp-export-delegate.js 43-46), so with a slow collector
spans drop from about 8 concurrent chats of 4 spans.
The Notes section now says both and documents
flushTraces(tracerProvider, { timeoutMs }).
Documentation only. The slow-collector concurrency case is described,
not tested (no fixture for 10 concurrent routes against a delaying
collector).
approved: Nikhil 2026-10-03 blanket
Refs: TH-8238
…ested set The example ships no lockfile. @tanstack/ai-openai 0.26.0 depends on @tanstack/openai-base ^0.12.2, which now resolves to 0.12.6: that release needs @tanstack/ai ^0.66.0 and openai 7, so a fresh `npm install` next to the pinned @tanstack/ai 0.64.0 gives an invalid peer tree (`npm ls`: ELSPROBLEMS, "@tanstack/[email protected] invalid: ^0.66.0") and two openai majors. 0.12.2 is the only 0.12.x whose peer range accepts 0.64.0. Add it to `overrides` next to the SDK pin and say why in the README. Contract suite (15) and node --test (10) pass on the pinned tree. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The recipe gave the chat() root a kind only when it ran more than one model call. fi-collector stores a span without a kind as observation type "unknown", so every plain chat() (one model call, no tool round) and every failed first call showed an "unknown" root in Observe (live run: `[unknown] chat no-such-model-th8103`). The root is now AGENT with more than one model call and CHAIN otherwise. Co-Authored-By: Claude Opus 5.5 <[email protected]>
otelMiddleware copies the adapter name to gen_ai.system, so Observe showed Provider "openai-chat" (openaiChatCompletions) and no vendor logo. The recipe now adds gen_ai.provider.name, which fi-collector reads first, on the chat and model-call spans, from the adapter names of the published adapters: openai / openai-chat -> openai, anthropic, gemini / gemini-text-interactions -> google, grok -> xai, groq, ollama. Other adapters (OpenRouter, custom) get none. Live: claude-haiku-4-5-20251001 through openaiChatCompletions -> provider openai, cost 0.000851 / 0.000794 per model call (fi-collector prices from gen_ai.request.model and tokens). Co-Authored-By: Claude Opus 5.5 <[email protected]>
The example installs outside the pnpm workspace and had no lockfile, so every transitive version floated (a fresh install had already picked an @tanstack/openai-base that needs a newer @tanstack/ai). Commit the npm lockfile, generated with npm 10.9.3 on Node 22.18.0 against the pinned set and the overrides (@tanstack/ai 0.64.0, @tanstack/ai-openai 0.26.0, @tanstack/openai-base 0.12.2, openai 6.49.0, sdk-trace-node 2.11.0), and un-ignore it in .gitignore, which ignores **/package-lock.json for the pnpm workspace. README and the contract test now say `npm ci`. `npm ci` installs clean (npm ls exit 0); contract 15 passed, node --test 12/12. Co-Authored-By: Claude Opus 5.5 <[email protected]>
@tanstack/ai 0.69.0 (2026-10-13) is the latest release. The example now
pins it with @tanstack/ai-openai 0.30.0, @tanstack/openai-base 0.14.0 and
@opentelemetry/sdk-trace-node 2.12.0, and the lockfile is regenerated on
Node 22. otelMiddleware is byte-identical from 0.64.0 to 0.69.0; the
contract suite passes on both sets (0.64.0 with the old lockfile).
The exact-SDK guard in span_kinds.test.mjs moves to 2.12.0. The README
lists both tested sets, and the batch-processor note now says why
register({ batch: true }) has no effect on SDK 2.x (fi-core's
addSpanProcessor fills a list the provider never reads).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
otelMiddleware records a failed model call or tool with
span.recordException(error) and sets the error's message as the span
status whatever captureContent is. The message comes from the provider or
from the app's tool (local paths, ids, masked keys), and the exception
event carries the stack trace with local file paths and the user name. A
real run against local Future AGI stored a tool error's home-directory
path and user name in status_message.
The recipe now hands otelMiddleware a tracer whose spans replace
recordException and setStatus (futureAgiErrorPrivacy, public Tracer and
Span API only). A stack trace is never exported. With content capture off,
the span gets error.type (the provider's error code, such as 400,
model_not_found or context_overflow, or the error class name), and the
status message and the exception event carry only that type. With
captureContent: true the message is exported through the same redact as
content. TanStack's fixed "cancelled" status for an abort is kept.
futureAgiOtelMiddleware(tracer, { captureContent, redact }) is the
documented content opt-in; capture_content_on.mjs now uses it. New
tool_error.mjs fixture and contract tests cover a tool error with content
off and on; the model-failure test asserts the type instead of the text;
node unit tests cover the wrapper against the real SDK span.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
TanStack starts the chat root span under the active OpenTelemetry context. In an app with its own provider and context manager (HTTP server instrumentation, a framework's own tracing) that is the app's request span, which is exported elsewhere. Future AGI then stores a trace whose root has a parent it never receives, and the Tracing list does not show the trace at all (seen on local Future AGI: "No traces found", while the trace view still opened). This was the open P3 follow-up F1 on this PR. futureAgiSpanKinds now returns onBeforeSpanStart, which adds root: true to the chat span's options. Model-call and tool spans are parented explicitly by TanStack, so they follow the root. joinActiveTrace: true keeps TanStack's parenting for apps whose own spans also go to Future AGI. New app_parent.mjs fixture and contract test (with and without joinActiveTrace) plus a node unit test; README section "Apps with their own OpenTelemetry setup". Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
TanStack AI recipe for TH-8238. No traceAI package: TanStack AI's own
otelMiddlewarecreates the spans, and@traceai/fi-coresupplies the provider and the exporter. This PR adds a runnable example undertypescript/examples/tanstack-ai/and a shared-harness contract test.@tanstack/ai0.64.0 is a dependency of the example only. The example has its own install outside the pnpm workspace, so the root lockfile is untouched.otelMiddlewareis imported from@tanstack/ai/middlewares/otel. It is not exported from@tanstack/ai(src/middlewares/index.ts:15-18). The docs draft gets the same fix.gen_ai.operation.name(otel.ts:623-628).onSpanEnd, which runs before the root ends.applyRootUsage). Future AGI sums promotedgen_ai.usage.*over the whole trace, so the recipe moves every root usage key (including cache-read and reasoning tokens) totanstack.ai.root_usage.*. It does so only when the model-call spans carry usage; otherwise the root keeps it as the only copy. The internal sdk-trace span the move edits is pinned (overridessdk-trace-node 2.11.0) and guarded by tests.session.idon every span.captureContentstays at its default (false). Flush and shutdown never throw, and a down collector is logged without failing the request.Deviations (with evidence)
register({batch: true})does not attach the batch processor. That bug is filed separately; the example flushes infinally..mjs, sonoderuns it on Node 20 without a TS toolchain.Tests (run on d871239, clean tree, by the coordinator)
The implementer also ran Node 22.23.3 (15 passed, 10/10). The root
typescript/pnpm-lock.yamlis unchanged, and every change is undertypescript/examples/tanstack-ai/.Limits
gen_ai.usage.costand cache-creation tokens were not exercised.Review
Round 1, an independent fresh-context review of 04409aa (a subagent, not an installed card): CHANGES_REQUESTED. Fixed test first.
session.idR6 was out of scope for this round, as the review noted. Round 2: installed pr-reviewer, then pr-verifier, on 5d175af. Verdicts will be added here.
Round 2 follow-ups (5d175af..d871239)
The installed review passed this PR at
5d175af: pr-reviewer t_f2e3000c APPROVE, pr-verifier t_5ae10b65 VERIFIED. Its findings were fixed before the demo. Run against5d175af, the new tests give 3 contract failures and 2 node-test failures.tracing.mjsoverridestoo (87a19fb)d871239)chat.mjswire the request signal tochat({abortController}). A new contract journey with a real client disconnect passes for both. Before the fix: "route still running 6 s after the client disconnected; exported spans: []" (a9555c4)engines >=20admits Node versions the SDK does not support>=20.6.0; the README lists only Node versions that ran (186831f)threadIdis copied verbatim tosession.id9bf9cf8)setGlobalTracerProvider: false, tested (cbb2aa0)captureContentoff43f4e23)Round 3 (installed pr-verifier t_f65677a1 at
d871239): VERIFIED, ready to merge.Review is complete at
d871239. One P3 follow-up for after merge. It is pre-existing, not introduced here:{ ...options, root: true }forchatspans fromonBeforeSpanStart, or document it. Spans still export and are counted once.Video demo
Narrated terminal demo, 6:00, recorded at head
d871239(the reviewed head), 1080p H.264/AAC, 9 chapters. sha256b920099b53d9c28b29745c18e6f36df3a0761764e1c695c86e0c84fc3583582f.The video, captions, transcript, chapter list, preview, recorded terminal output and media check are attached privately to Linear TH-8238 (Future AGI workspace access needed). Every run uses a loopback fake OpenAI-compatible model, placeholder keys and the shared harness receiver: no vendor call and no live fi-collector.
d871239: branch, 0 uncommitted files, 16 commits on3eaadc8/tracer/v1/traceswith both auth headers (values not shown);gen_ai.usage.input_tokensover the trace 11 + 23 = 34 with none on the root (TanStack's sum kept astanstack.ai.root_usage.input_tokens=34);session.idfromchat()'s threadId on 4/4 spans; no content markers, keys or span events by default, and the capture-on control fixture shows the check catches them; a failing model call exits 1 with two ERROR spanssrc/chat.mjsstop within 1 s and export both spans ended as cancelled; atcbb2aa0(before the fix) the route is still running after 6 s and nothing is exportedsetGlobalTracerProvider: falsethe app keeps its own provider; at5d175affi-core took the global and the app's span went to Future AGI>=20.6.0; the 15-test contract suite passed on the first take, includingtest_silent_collector_does_not_hold_the_responsenode --testspan kinds 10/10, 0 uncommitted files afterThe host was loaded during recording, so VHS compresses idle time; printed timings are real and no output was removed. Checked by the coordinator: sha256, full decode, chapter markers, no pause of 2.5 s or more, frames per chapter against the driver output and the recorded terminal output, no keys or header values in frames, captions or transcript.
Stacked on #203. Not merged.
Linear: TH-8238. approved: Nikhil 2026-10-03 blanket
Verification sweep (2026-10-12)
dev, and the spans were checked in ClickHouse and in the Observe UI (hierarchy, span types, input/output, model and tokens, sessions, error status, and a secret scan of everything stored).Re-verification (2026-10-16)
Re-verified against the latest TanStack AI, a real local Future AGI (Observe UI + ClickHouse) and a live OpenAI key. Three commits added on top of
2ab70c5; head is nowf2295cb.What changed and why
de821d7@tanstack/ai0.69.0,@tanstack/ai-openai0.30.0,@tanstack/openai-base0.14.0,@opentelemetry/sdk-trace-node2.12.0; lockfile regenerated on Node 22; SDK guard test moved to 2.12.0otelMiddleware(dist/esm/middlewares/otel.js) is byte-identical from 0.64.0 to 0.69.0, so no API moved; the PR's own suite passed on 0.69.0 except the exact-SDK-version guard.879b418futureAgiErrorPrivacy: the tracer handed tootelMiddlewarewraps each span (Proxy, public Tracer/Span API only) so a stack trace is never exported and, with content off, the error message is replaced byerror.type(provider code such as400/model_not_found, else the class name).futureAgiOtelMiddleware(tracer, { captureContent, redact })is the documented opt-in.span.recordException(error)and sets the error message as the status whatevercaptureContentis. Real run on2ab70c5(trace4686f706): a tool error storedTOOLERR-<marker> cannot read <home>/private/<marker>.json for <user>instatus_message, and the exception event carried the stack trace on the wire. The TH-8103 privacy rule is that paths, user names and stack traces are never exported and error text is off by default in recipes. The PR's test previously asserted that the stack trace was exported.f2295cbonBeforeSpanStartreturns{ ...options, root: true }for the chat span (opt out:joinActiveTrace: true)213555a8): an app with its own provider + context manager and an active request span made the chat root a child of a span Future AGI never receives, and the Tracing list showed "No traces found" (the trace view still opened by id). After the fix the same scenario (8ca9c68e) is a normal root and is listed.Also: README rewritten for the pins, the error privacy, the opt-in, the new-trace root and two Future AGI display limits (below);
src/chat.mjscomment now says thestream: falsehazard holds on 0.64.0 to 0.69.0 (re-checked: with a failing model,stream: falseexports no span at 0.69.0).Versions
@tanstack/ai0.69.0,@tanstack/ai-openai0.30.0,@tanstack/openai-base0.14.0,openai7.36.0,@traceai/fi-core1.0.0,@opentelemetry/api1.9.1, SDK 2.12.0.@tanstack/ai0.64.0 /ai-openai0.26.0 /openai-base0.12.2 / SDK 2.11.0 (old lockfile,npm ci), with the newtracing.mjs.Tests
New:
test_failed_tool_exports_its_error_type_not_its_text,test_content_opt_in_exports_error_text_but_never_a_stack_trace,test_chat_root_starts_a_new_trace_under_an_app_span[False|True];test_failed_model_call_exports_error_spansnow assertserror.type/status500, noboom, no stack; 6 new node unit tests for the wrapper (real SDK span) and the root option. Mutation check: with the wrapper and the root option removed, 5 of these fail.Real E2E against local Future AGI (fi_verify PASS on every project)
Run exactly as the README says (
node src/chat.mjs "<question>" <thread-id>), plus a route whose tool throws and an app with its own active span.gpt-6-lunavia OpenAI-compatible endpoint334d8cd4tool run (agent → llm → tool, llm),03d76067tool throws (tool ERRORError),80067e9eunknown model (chain + llm ERRORmodel_not_found),8ca9c68eunder an app span (own root, listed)gpt-4o-mini, real keye30097eftool run,08a3a0cctool throws,19b596afunknown model (404→ ERRORmodel_not_found)gpt-6-lunab5b88556,c5d41370,94b7da7a(same shapes)captureContent: true8d8ba344,04ca40d9fi_verify excerpt (live project):
Stored columns (live): provider
openai, response modelgpt-4o-mini-2024-07-18, per-call cost0.0000184/0.0000258,session.idon every span of a threaded run, tokens only on the llm spans (root keepstanstack.ai.root_usage.*). Total live spend about $0.0001. Observe UI screenshots checked by eye: trace list (4 traces incl. the app-span one), agent/llm/tool tree, LLM span with model/provider/tokens/cost, tool span showingError/error.typeand no message.Privacy proof
Unique marker per run in the user message and system prompt; the tool returned it, and in the failure runs the tool threw
TOOLERR-<marker> cannot read <home>/private/<marker>.json for <user>; the prompt also carried an email address. Scan of every column of every ClickHouse row of the project (spans, hourly rollups, per-session, trace sessions, traces, trace dict) plus the Postgres project row:TOOLERR-file:///stacktrace2ab70c5)In the opt-in run the tool's own error text (which names a home path) is exported as the documented opt-in says; the stack trace is still absent. "—" = not applicable to that run.
Resource attributes stored:
project_name,project_type,project_version_name,project_version_id,eval_tags,metadata,service.name(no host/process/user attributes).Limitations found (Future AGI side, documented in README and docs page)
trace_detail.py,session_list.py). Keeping usage on the root shows list tokens but doubles every sum (checked: root promoted toprompt_tokens=760next to llm 358 + 402). The recipe keeps the move, so the list cell is empty for TanStack traces. Platform item: make the list sum like the trace view.gen_ai.input.messages/gen_ai.output.messagesstay in attributes; the Input/Output panes are empty (same as the Flue, Mirascope and Eve recipes).Docs
Docs page: future-agi/docs#1013 (draft, base
main, nonavigation.ts):src/pages/docs/integrations/frameworks/tanstack-ai.mdx. Suggested nav: Integrations → Frameworks, title "TanStack AI".🤖 Generated with Claude Code