Repository navigation
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 sdk-trace-node@2.11.0 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
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