Skip to content

feat(examples): TanStack AI otelMiddleware recipe (TH-8238) - #212

Merged
nik13 merged 27 commits into
devfrom
feat/th-8238-tanstack-ai
Oct 26, 2026
Merged

nik13 merged 27 commits into
devfrom
feat/th-8238-tanstack-ai

Conversation

@nik13

@nik13 nik13 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

TanStack AI recipe for TH-8238. No traceAI package: TanStack AI's own otelMiddleware creates the spans, and @traceai/fi-core supplies the provider and the exporter. This PR adds a runnable example under typescript/examples/tanstack-ai/ and a shared-harness contract test.

  • @tanstack/ai 0.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.
  • Import path: otelMiddleware is 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.
  • Kinds: model-call spans are LLM and tool spans are TOOL. The chat root is AGENT when it ran more than one model call.
    • Kinds are keyed on the middleware's span type rather than the operation name. Only model-call spans carry gen_ai.operation.name (otel.ts:623-628).
    • The root's kind is set in onSpanEnd, which runs before the root ends.
  • Token double-count fixed: TanStack sums every call's usage onto the root (applyRootUsage). Future AGI sums promoted gen_ai.usage.* over the whole trace, so the recipe moves every root usage key (including cache-read and reasoning tokens) to tanstack.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 (overrides sdk-trace-node 2.11.0) and guarded by tests.
  • Session: the caller's thread id is sent as session.id on every span.
  • Failures: a failed model call still exports its error spans. The flush on the request path is bounded, so a hanging collector no longer holds the response for about 10 s. The README snippet is the tested recipe.
  • Content: captureContent stays at its default (false). Flush and shutdown never throw, and a down collector is logged without failing the request.

Deviations (with evidence)

  • No batch processor. With fi-core 1.0.0 on sdk-trace 2.x, register({batch: true}) does not attach the batch processor. That bug is filed separately; the example flushes in finally.
  • The example is plain .mjs, so node runs it on Node 20 without a TS toolchain.

Tests (run on d871239, clean tree, by the coordinator)

PYTHONPATH="python/tests" uv run --no-project --python 3.11 --with pytest --with protobuf --with opentelemetry-proto pytest typescript/examples/tanstack-ai/tests -q -p no:cacheprovider --noconftest -o addopts= -rs
(cd typescript/examples/tanstack-ai && node --test tests/span_kinds.test.mjs)
Node 26.8.1: contract 15 passed; node --test 10/10
Node 20.20.2: contract 15 passed; node --test 10/10

The implementer also ran Node 22.23.3 (15 passed, 10/10). The root typescript/pnpm-lock.yaml is unchanged, and every change is under typescript/examples/tanstack-ai/.

Limits

  • No live vendor call, and no real fi-collector.
  • gen_ai.usage.cost and cache-creation tokens were not exercised.
  • A docs PR is not opened here; the draft is in company-brain PR Dev #62.

Review

Round 1, an independent fresh-context review of 04409aa (a subagent, not an installed card): CHANGES_REQUESTED. Fixed test first.

ID Finding Resolution Commit
R1 (blocking) A failed model call (stream:false) exported nothing Error spans exported; fake model returns HTTP 500 73d9943
R2 The README snippet double-counts, and its flush can replace the answer README is now the tested recipe (2 failed before) 98f7444
R3 Only 3 root usage keys moved Every usage key moved off the root 4a114d1
R4 No session, and the gap not declared Thread id becomes session.id 48e2af0
R5 A hanging collector delays the response ~10 s Bounded flush (13.5 s → about 2.2 s) c6001eb
R7 Internal-API dependency, pin, missing tests, key hygiene Pinned and guarded (056bb44); usage-branch tests catch mutation M2 (4a114d1); placeholder-key checks (5d175af) 056bb44, 4a114d1, 5d175af

R6 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 against 5d175af, the new tests give 3 contract failures and 2 node-test failures.

ID Finding Resolution
N1 The sdk-trace pin does not travel with a copied tracing.mjs README: copy the overrides too (87a19fb)
N2 Serverless flush and in-flight limits undocumented README Notes (d871239)
N3 A disconnect without an abortController leaves spans open README route and chat.mjs wire the request signal to chat({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)
N4 engines >=20 admits Node versions the SDK does not support >=20.6.0; the README lists only Node versions that ran (186831f)
N5 threadId is copied verbatim to session.id README advises an opaque id (9bf9cf8)
N6 The recipe replaced the global tracer provider setGlobalTracerProvider: false, tested (cbb2aa0)
N7 Error text is exported even with captureContent off README privacy; test pins it (43f4e23)

Round 3 (installed pr-verifier t_f65677a1 at d871239): VERIFIED, ready to merge.

  • N1-N7 are confirmed fixed.
  • No regressions:
    • Promoted usage still lands once per model call, including on the new abort path.
    • No key material appears in spans or stdout.
    • The flush bound cannot hang or throw.

Review is complete at d871239. One P3 follow-up for after merge. It is pre-existing, not introduced here:

  • In an app with its own OpenTelemetry context manager (the setup N6 now allows), the TanStack chat root span inherits the app's active span as its parent. The Future AGI trace then shows a parent that was exported elsewhere. Two possible mitigations: return { ...options, root: true } for chat spans from onBeforeSpanStart, 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. sha256 b920099b53d9c28b29745c18e6f36df3a0761764e1c695c86e0c84fc3583582f.

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.

Time Chapter
00:00 The problem
00:53 Head d871239: branch, 0 uncommitted files, 16 commits on 3eaadc8
01:18 Chat (Node 26): 4 spans in 1 trace (AGENT / LLM / TOOL / LLM), 4 exports to /tracer/v1/traces with both auth headers (values not shown); gen_ai.usage.input_tokens over the trace 11 + 23 = 34 with none on the root (TanStack's sum kept as tanstack.ai.root_usage.input_tokens=34); session.id from chat()'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 spans
02:30 Disconnect: a real HTTP client disconnects mid-stream; the README route and src/chat.mjs stop within 1 s and export both spans ended as cancelled; at cbb2aa0 (before the fix) the route is still running after 6 s and nothing is exported
03:03 Global provider: with setGlobalTracerProvider: false the app keeps its own provider; at 5d175af fi-core took the global and the app's span went to Future AGI
03:37 Node 20.20.2: engines >=20.6.0; the 15-test contract suite passed on the first take, including test_silent_collector_does_not_hold_the_response
04:19 The change: N3 abort wiring, N6 register options, N4 engines, N7 README privacy note
04:58 Tests (Node 26): contract 15 passed, node --test span kinds 10/10, 0 uncommitted files after
05:27 Review and limits

The 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)

  • Verified end to end: the real library made real calls, traces were exported to a local Future AGI stack built from 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).
  • Commits added in this sweep:
    • a6c3331 fix(examples): pin @tanstack/openai-base so npm install matches the tested set
    • 175fb97 fix(examples): mark a single-call TanStack AI chat root as CHAIN
    • 9ee8a33 fix(examples): send gen_ai.provider.name for TanStack AI model calls
    • 2ab70c5 build(examples): commit the TanStack AI example's package-lock.json
  • Merge order: merge test(harness): shared OTLP harness for TH-8103 contract tests #203 first (merge commit), and the README catalog PR docs: list the TH-8103 integrations in the README catalogs #250 last. Everything else is order-independent.

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 now f2295cb.

What changed and why

Commit Change Why
de821d7 Example pinned to @tanstack/ai 0.69.0, @tanstack/ai-openai 0.30.0, @tanstack/openai-base 0.14.0, @opentelemetry/sdk-trace-node 2.12.0; lockfile regenerated on Node 22; SDK guard test moved to 2.12.0 0.69.0 (2026-10-13) is the npm latest. otelMiddleware (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.
879b418 futureAgiErrorPrivacy: the tracer handed to otelMiddleware wraps each span (Proxy, public Tracer/Span API only) so a stack trace is never exported and, with content off, the error message is replaced by error.type (provider code such as 400 / model_not_found, else the class name). futureAgiOtelMiddleware(tracer, { captureContent, redact }) is the documented opt-in. TanStack calls span.recordException(error) and sets the error message as the status whatever captureContent is. Real run on 2ab70c5 (trace 4686f706): a tool error stored TOOLERR-<marker> cannot read <home>/private/<marker>.json for <user> in status_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.
f2295cb onBeforeSpanStart returns { ...options, root: true } for the chat span (opt out: joinActiveTrace: true) This was the open P3 follow-up F1. Reproduced on local Future AGI (trace 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.mjs comment now says the stream: false hazard holds on 0.64.0 to 0.69.0 (re-checked: with a failing model, stream: false exports no span at 0.69.0).

Versions

  • Primary: @tanstack/ai 0.69.0, @tanstack/ai-openai 0.30.0, @tanstack/openai-base 0.14.0, openai 7.36.0, @traceai/fi-core 1.0.0, @opentelemetry/api 1.9.1, SDK 2.12.0.
  • Matrix leg: the PR's original pins, @tanstack/ai 0.64.0 / ai-openai 0.26.0 / openai-base 0.12.2 / SDK 2.11.0 (old lockfile, npm ci), with the new tracing.mjs.
  • Node 26.8.1 and 22.18.0.

Tests

PYTHONPATH="python/tests" uv run --no-project --python 3.11 --with pytest --with protobuf --with opentelemetry-proto \
  pytest typescript/examples/tanstack-ai/tests -q -p no:cacheprovider --noconftest -o addopts=
0.69.0, Node 26.8.1: 19 passed      0.69.0, Node 22.18.0: 19 passed
0.64.0 set, Node 26.8.1: 18 passed, 1 deselected (the exact-SDK-2.12.0 guard); node --test 17/17 with that guard skipped
node --test tests/span_kinds.test.mjs (0.69.0): 18/18

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_spans now asserts error.type/status 500, no boom, 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.

Observe project Upstream / model Traces
default 0.69.0, gpt-6-luna via OpenAI-compatible endpoint 334d8cd4 tool run (agent → llm → tool, llm), 03d76067 tool throws (tool ERROR Error), 80067e9e unknown model (chain + llm ERROR model_not_found), 8ca9c68e under an app span (own root, listed)
live Live vendor verification (2026-10-17): 0.69.0, OpenAI gpt-4o-mini, real key e30097ef tool run, 08a3a0cc tool throws, 19b596af unknown model (404 → ERROR model_not_found)
floor 0.64.0 set, gpt-6-luna b5b88556, c5d41370, 94b7da7a (same shapes)
opt-in 0.69.0, captureContent: true 8d8ba344, 04ca40d9

fi_verify excerpt (live project):

trace e30097ef-d2b1-400a-9026-23a31a822d35 (4 spans) session=114454bd-...
- [agent] chat gpt-4o-mini model=gpt-4o-mini
  - [llm] chat gpt-4o-mini #0 model=gpt-4o-mini tok=67/14
    - [tool] execute_tool get_weather
  - [llm] chat gpt-4o-mini #1 model=gpt-4o-mini tok=104/17
trace 08a3a0cc-... - [tool] execute_tool get_weather status=ERROR:Error
trace 19b596af-... - [chain] chat gpt-th8238-no-such-model status=ERROR:model_not_found
                     - [llm] chat gpt-th8238-no-such-model #0 status=ERROR:model_not_found
PASS

Stored columns (live): provider openai, response model gpt-4o-mini-2024-07-18, per-call cost 0.0000184 / 0.0000258, session.id on every span of a threaded run, tokens only on the llm spans (root keeps tanstack.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 showing Error / error.type and 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:

Observe project Rows Marker TOOLERR- home path user name host name email file:// / stacktrace FI + model keys
default (0.69.0) 14 spans 0 0 0 0 0 0 0 0
live OpenAI 10 spans 0 0 0 0 0 n/a 0 0
0.64.0 set 10 spans 0 0 0 0 0 n/a 0 0
opt-in 8 spans 8 rows (attributes) 2 rows (status message) — — 0 — 0 0
before the fix (2ab70c5) 10 spans 1 1 1 1 0 — 0 stored (sent on the wire) 0

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)

  • The Tracing list's Tokens cell reads the root span, while trace view / sessions / dashboards / spend monitors sum every span (trace_detail.py, session_list.py). Keeping usage on the root shows list tokens but doubles every sum (checked: root promoted to prompt_tokens=760 next 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.
  • With content on, gen_ai.input.messages / gen_ai.output.messages stay 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, no navigation.ts): src/pages/docs/integrations/frameworks/tanstack-ai.mdx. Suggested nav: Integrations → Frameworks, title "TanStack AI".

🤖 Generated with Claude Code

nik13 added 5 commits October 3, 2026 18:58
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
nik13 added 14 commits October 4, 2026 16:44
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
@nik13
nik13 requested a review from NVJKKartik October 10, 2026 08:15
nik13 and others added 7 commits October 11, 2026 20:04
…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]>
@nik13
nik13 merged commit 49b3a5e into dev Oct 26, 2026
4 checks passed
@nik13
nik13 deleted the feat/th-8238-tanstack-ai branch October 26, 2026 10:02
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.

1 participant