TanStack AI tracing with Future AGI

Send the OpenTelemetry spans TanStack AI's own otelMiddleware creates to Future AGI with a tracer provider from @traceai/fi-core. Covers what is captured, the metadata-only default, the content opt-in, sessions, errors and apps that already run OpenTelemetry.

Trace server-side TanStack AI chat() calls in Future AGI using TanStack’s OpenTelemetry middleware and @traceai/fi-core. This guide uses the OpenAI Chat Completions adapter in Node.js.

The middleware creates chat, model-call and tool-call spans. The recipe below configures export, adds Future AGI span kinds, avoids counting token usage twice and filters error details. Use only one TanStack tracing middleware to avoid duplicate spans.

1. Install

npm install @tanstack/ai@0.69.0 @tanstack/ai-openai@0.30.0 @traceai/fi-core@1.0.0 @opentelemetry/api@1.9.1

Pin the SDK and adapter dependency versions in your app’s package.json:

{
  "overrides": {
    "@opentelemetry/sdk-trace-node": "2.12.0",
    "@tanstack/openai-base": "0.14.0"
  }
}

npm reads overrides; pnpm uses pnpm.overrides and Yarn resolutions. The recipe moves the chat span’s summed usage by editing the SDK span’s attributes object, which is not public OpenTelemetry API, so this recipe pins the SDK to 2.12.0. TanStack’s adapter packages move in lockstep with @tanstack/ai, so the @tanstack/openai-base pin keeps an install from mixing versions.

2. Set environment variables

export FI_API_KEY="YOUR_API_KEY"        # sent as X-Api-Key
export FI_SECRET_KEY="YOUR_SECRET_KEY"  # sent as X-Secret-Key
export FI_PROJECT_NAME="my-chatbot"     # the Observe project the traces land in
# Self-hosted Future AGI only. Traces go to $FI_BASE_URL/tracer/v1/traces.
# export FI_BASE_URL="http://localhost:4318"    # default: https://api.futureagi.com
export OPENAI_API_KEY="YOUR_OPENAI_KEY" # read by @tanstack/ai-openai

Set FI_BASE_URL to the origin only; register() adds /tracer/v1/traces. Keep every key on the server. Never import tracing.mjs into a browser bundle.

3. Add the recipe file

Save this as tracing.mjs (or tracing.ts, it is plain ES module JavaScript) next to your route code. It is the same file as the example’s src/tracing.mjs.

// Future AGI tracing for TanStack AI. This is a recipe, not a package:
// TanStack AI's own otelMiddleware creates the spans, and @traceai/fi-core
// only supplies the tracer provider and the exporter.
import { SpanStatusCode } from "@opentelemetry/api";
import { register, ProjectType } from "@traceai/fi-core";
// otelMiddleware is exported from this subpath only, not from "@tanstack/ai"
// (@tanstack/ai 0.64.0 to 0.69.0, src/middlewares/index.ts).
import { otelMiddleware } from "@tanstack/ai/middlewares/otel";

export const GEN_AI_SPAN_KIND = "gen_ai.span.kind";

/**
 * Register the Future AGI tracer provider.
 *
 * register() exports OTLP/HTTP to {FI_BASE_URL}/tracer/v1/traces, sends
 * FI_API_KEY and FI_SECRET_KEY as the X-Api-Key and X-Secret-Key headers, and
 * sets the project_name and project_type resource attributes the collector
 * requires. A batch without project_name is dropped.
 *
 * `batch: true` is not passed: with @traceai/fi-core 1.0.0 on
 * @opentelemetry/sdk-trace(-base) 2.11.0 it does not attach the batch
 * processor, so spans go through register()'s default SimpleSpanProcessor.
 *
 * `setGlobalTracerProvider: false`: the recipe passes this provider's tracer
 * to otelMiddleware, so it never needs the global provider, and leaving the
 * global slot alone keeps an app's own OpenTelemetry setup (and its other
 * spans) where it was. Removing the recipe is then dropping the middleware
 * and calling shutdownTraces().
 */
export function registerFutureAgiTracing() {
  return register({
    projectType: ProjectType.OBSERVE,
    projectName: process.env.FI_PROJECT_NAME ?? "tanstack-ai-example",
    setGlobalTracerProvider: false,
  });
}

function describe(error) {
  const errors = Array.isArray(error) ? error : [error];
  return errors.map((e) => e?.message ?? String(e)).join("; ");
}

/** How long the route waits for span export before it answers anyway. */
export const FLUSH_TIMEOUT_MS = 2000;

/**
 * Wait for in-flight span exports, at most `timeoutMs`. Call it in the
 * route's finally. Never throws: an unreachable collector is logged, and a
 * collector that does not answer within the bound is logged and left to
 * finish in the background (OTLP/HTTP gives up after its own 10 s timeout).
 *
 * This is for serverless routes, where the process may be frozen once the
 * response is sent. In a long-lived server, do not await a flush on the
 * request path at all: the SimpleSpanProcessor already exports each span when
 * it ends, so call shutdownTraces() when the process stops instead.
 */
export async function flushTraces(tracerProvider, { timeoutMs = FLUSH_TIMEOUT_MS } = {}) {
  let timer;
  const timedOut = new Promise((resolve) => {
    timer = setTimeout(() => resolve(true), timeoutMs);
    timer.unref?.();
  });
  try {
    const flushed = tracerProvider.forceFlush().then(() => false);
    if (await Promise.race([flushed, timedOut])) {
      console.error(
        `[futureagi] span export still pending after ${timeoutMs} ms; not waiting`,
      );
      flushed.catch((error) => {
        console.error(`[futureagi] span export failed: ${describe(error)}`);
      });
    }
  } catch (error) {
    console.error(`[futureagi] span export failed: ${describe(error)}`);
  } finally {
    clearTimeout(timer);
  }
}

/** Stop the exporter at process exit (shutdown flushes first). Never throws. */
export async function shutdownTraces(tracerProvider) {
  try {
    await tracerProvider.shutdown();
  } catch (error) {
    console.error(`[futureagi] tracer shutdown failed: ${describe(error)}`);
  }
}

/**
 * Future AGI span kinds for the spans otelMiddleware emits.
 *
 * Keyed on the middleware's span scope (`info.kind`), not on
 * gen_ai.operation.name: at @tanstack/ai 0.64.0 only iteration spans carry
 * gen_ai.operation.name ("chat"); the root and tool spans do not.
 *
 * - iteration (one provider model call) -> LLM
 * - tool (one tool execution)          -> TOOL
 * - chat root that ran more than one iteration -> AGENT, else CHAIN
 *
 * The root's kind is set in onSpanEnd because attributeEnricher runs for the
 * root when chat() starts, before the iteration count is known. A root with
 * no kind would be stored by Future AGI as observation type "unknown". Media
 * "generation" spans get no kind.
 *
 * Usage: TanStack sums every model call's gen_ai.usage.* keys onto the root
 * (otel.ts applyRootUsage, set just before onSpanEnd): input, output and
 * total tokens, cost, cache read/creation and reasoning tokens. Future AGI
 * promotes gen_ai.usage.* (and gen_ai.cost.*) into its token and cost
 * columns on any span and sums them over the whole trace, so leaving the
 * root's copy would count each call twice. When the model-call spans carry
 * usage, every gen_ai.usage.<suffix> key on the root moves to
 * tanstack.ai.root_usage.<suffix>, and every gen_ai.cost.<suffix> key to
 * tanstack.ai.root_usage.cost.<suffix>. If no model-call span reported
 * usage, the root keeps it, because then it is the only copy.
 */
const PROMOTED_PREFIXES = ["gen_ai.usage.", "gen_ai.cost."];

function rootUsageKey(key) {
  return key.startsWith("gen_ai.usage.")
    ? `tanstack.ai.root_usage.${key.slice("gen_ai.usage.".length)}`
    : `tanstack.ai.root_usage.${key.slice("gen_ai.".length)}`;
}

function moveRootUsage(span) {
  // Internal dependency: OpenTelemetry has no public API to remove a span
  // attribute. This edits the SDK span's `attributes` object, which the
  // exporter reads at end(). Keep the SDK override above. `delete` does not
  // lower SpanImpl's private attribute count, so under a tight attributeCountLimit the
  // tanstack.ai.root_usage.* copy can be dropped. The promoted key still
  // leaves the root, so a trace's usage is never counted twice.
  const attributes = span?.attributes;
  if (!attributes) return;
  for (const key of Object.keys(attributes)) {
    if (!PROMOTED_PREFIXES.some((prefix) => key.startsWith(prefix))) continue;
    const value = attributes[key];
    delete attributes[key];
    span.setAttribute(rootUsageKey(key), value);
  }
}

export const SESSION_ID = "session.id";
export const GEN_AI_PROVIDER_NAME = "gen_ai.provider.name";

/**
 * TanStack adapter name (`adapter.name`, which otelMiddleware copies to
 * gen_ai.system) -> gen_ai.provider.name, the key fi-collector reads first for
 * a span's provider. Names from the published adapters: openaiChatCompletions
 * is "openai-chat", openaiText "openai", @tanstack/ai-gemini "gemini" and
 * "gemini-text-interactions", @tanstack/ai-grok "grok". Any other adapter
 * (OpenRouter, a custom one) leaves the provider unset. Future AGI prices a
 * call from gen_ai.request.model and its tokens, not from the provider.
 */
const ADAPTER_PROVIDERS = {
  openai: "openai",
  "openai-chat": "openai",
  anthropic: "anthropic",
  gemini: "google",
  "gemini-text-interactions": "google",
  grok: "xai",
  groq: "groq",
  ollama: "ollama",
};

/**
 * Span kinds, usage and session for the spans otelMiddleware emits.
 *
 * With `threadIdAsSession: true`, every span also gets
 * session.id = ctx.threadId, the `threadId` the caller passed to chat().
 * It is exported verbatim, so pass an opaque id (a UUID), never an email
 * address or user name.
 * Set it only when the caller passed one: chat() otherwise generates a
 * fresh thread-<ms>-<random> id per call, which would make every request its
 * own session. @traceai/fi-core's setSession() only sets a context value;
 * neither otelMiddleware nor the plain SDK tracer register()'s provider
 * hands out reads it, so the recipe uses TanStack's own threadId instead.
 */
export function futureAgiSpanKinds({ threadIdAsSession = false, joinActiveTrace = false } = {}) {
  const iterationsByRun = new WeakMap();
  const usageOnIterations = new WeakSet();
  // TanStack starts the chat root under the active OpenTelemetry context, so in
  // an app with its own provider and context manager (an HTTP server span, a
  // framework's own tracing) the root's parent is a span that is exported
  // elsewhere. Future AGI then has no root for the trace, and the trace is
  // missing from the Tracing list. The root starts a new trace unless the
  // caller asks to join the active one (when that app's spans also go to
  // Future AGI). Model-call and tool spans are parented explicitly by
  // TanStack, so they follow the root either way.
  const onBeforeSpanStart = joinActiveTrace
    ? undefined
    : (info, options) => (info.kind === "chat" ? { ...options, root: true } : options);
  const kindAttributes = (info) => {
    if (info.kind === "iteration") {
      iterationsByRun.set(info.ctx, info.iteration + 1);
      return { [GEN_AI_SPAN_KIND]: "LLM" };
    }
    if (info.kind === "tool") {
      return { [GEN_AI_SPAN_KIND]: "TOOL" };
    }
    return {};
  };
  return {
    ...(onBeforeSpanStart ? { onBeforeSpanStart } : {}),
    attributeEnricher(info) {
      const attributes = kindAttributes(info);
      const adapter = info.ctx?.provider;
      const provider = Object.hasOwn(ADAPTER_PROVIDERS, adapter) ? ADAPTER_PROVIDERS[adapter] : undefined;
      if (provider && (info.kind === "chat" || info.kind === "iteration")) {
        attributes[GEN_AI_PROVIDER_NAME] = provider;
      }
      if (threadIdAsSession && info.ctx?.threadId) {
        attributes[SESSION_ID] = info.ctx.threadId;
      }
      return attributes;
    },
    onSpanEnd(info, span) {
      if (info.kind === "iteration" && span?.attributes?.["gen_ai.usage.input_tokens"] !== undefined) {
        usageOnIterations.add(info.ctx);
      }
      if (info.kind === "chat") {
        span.setAttribute(GEN_AI_SPAN_KIND, (iterationsByRun.get(info.ctx) ?? 0) > 1 ? "AGENT" : "CHAIN");
        if (usageOnIterations.has(info.ctx)) {
          moveRootUsage(span);
        }
      }
    },
  };
}

export const ERROR_TYPE = "error.type";
/** TanStack's own fixed status text for an aborted run; kept as is. */
const CANCELLED = "cancelled";
const REDACTION_FAILED = "[redaction_failed]";

/** A provider error code (RUN_ERROR code, HTTP status) or the error's class name. */
export function errorType(error) {
  const code = error?.code;
  if ((typeof code === "string" && code) || (typeof code === "number" && Number.isFinite(code))) {
    return String(code);
  }
  const name = error?.name;
  return typeof name === "string" && name ? name : "Error";
}

function errorText(error) {
  if (typeof error === "string") return error;
  const message = error?.message;
  return typeof message === "string" ? message : undefined;
}

/**
 * Wrap the tracer otelMiddleware uses, so error details are exported the way
 * content is.
 *
 * otelMiddleware calls span.recordException(error) and sets the error's
 * message as the span status whatever captureContent is. That text comes
 * from the provider or from your tool, and the exception event carries the
 * stack trace, with local file paths and the user name. Through this
 * tracer:
 *
 * - a stack trace is never exported;
 * - with content capture off (the default), the error's message is not
 *   exported either: the exception event keeps only exception.type, the
 *   span status message is that type, and the span gets error.type (the
 *   provider's error code, such as "400" or "context_overflow", or the
 *   error class name);
 * - with captureContent: true, the message is exported, passed through the
 *   same `redact` as content (a redactor that throws yields
 *   "[redaction_failed]").
 *
 * Only the public Tracer and Span API is used: each span is the SDK span
 * behind a Proxy that replaces recordException and setStatus.
 */
export function futureAgiErrorPrivacy(tracer, { captureContent = false, redact = (text) => text } = {}) {
  const safeRedact = (text) => {
    try {
      return redact(text);
    } catch {
      return REDACTION_FAILED;
    }
  };
  const wrap = (span) => {
    let lastType;
    const proxy = new Proxy(span, {
      get(target, property) {
        if (property === "recordException") {
          return (exception, time) => {
            lastType = errorType(exception);
            target.setAttribute(ERROR_TYPE, lastType);
            const text = captureContent ? errorText(exception) : undefined;
            target.recordException(
              text ? { name: lastType, message: safeRedact(text) } : { name: lastType },
              time,
            );
          };
        }
        if (property === "setStatus") {
          return (status) => {
            let next = status;
            if (
              status?.code === SpanStatusCode.ERROR &&
              typeof status.message === "string" &&
              status.message !== CANCELLED
            ) {
              next = {
                code: status.code,
                message: captureContent ? safeRedact(status.message) : (lastType ?? "Error"),
              };
            }
            target.setStatus(next);
            return proxy;
          };
        }
        const value = Reflect.get(target, property, target);
        return typeof value === "function" ? value.bind(target) : value;
      },
    });
    return proxy;
  };
  return {
    startSpan: (name, options, context) => wrap(tracer.startSpan(name, options, context)),
    startActiveSpan: (...args) => tracer.startActiveSpan(...args),
  };
}

/**
 * The one middleware to pass to chat().
 *
 * Content capture is off by default: no prompt, completion, system prompt,
 * tool argument or result, and no error message, reaches a span, and a
 * stack trace never does (see futureAgiErrorPrivacy).
 *
 * Options:
 * - `threadIdAsSession: true` when this chat() call gets the caller's
 *   `threadId` (see futureAgiSpanKinds).
 * - `captureContent: true` to opt in to message, tool and error text
 *   (TanStack's own option), with an optional `redact(text)`.
 * - `joinActiveTrace: true` to keep TanStack's chat root under the app's
 *   active span (only when the app's own spans also go to Future AGI).
 */
export function futureAgiOtelMiddleware(
  tracer,
  { threadIdAsSession = false, captureContent = false, redact, joinActiveTrace = false } = {},
) {
  return otelMiddleware({
    tracer: futureAgiErrorPrivacy(tracer, { captureContent, ...(redact ? { redact } : {}) }),
    captureContent,
    ...(redact ? { redact } : {}),
    ...futureAgiSpanKinds({ threadIdAsSession, joinActiveTrace }),
  });
}

4. Trace a route

Register the provider once per process, build the middleware per request, read chat()’s stream to the end, and flush in finally:

import { chat, toolDefinition } from "@tanstack/ai";
import { openaiChatCompletions } from "@tanstack/ai-openai";
import {
  flushTraces,
  futureAgiOtelMiddleware,
  registerFutureAgiTracing,
} from "./tracing.mjs";

// Once per process. register() reads FI_API_KEY, FI_SECRET_KEY, FI_BASE_URL.
const tracerProvider = registerFutureAgiTracing();

// A server tool, so a question about the weather produces a tool span.
// Replace it with your own tools; each one that runs gets an execute_tool span.
const getWeather = toolDefinition({
  name: "get_weather",
  description: "Get the current weather for a city.",
  inputSchema: {
    type: "object",
    properties: { city: { type: "string" } },
    required: ["city"],
  },
}).server(async ({ city }) => ({ city, forecast: "sunny", celsius: 21 }));

// `signal` is the request's abort signal: `request.signal` in a Fetch API
// handler, or an AbortController you abort on `res.on("close")` in node:http.
export async function chatRoute(question, { threadId, signal } = {}) {
  const middleware = futureAgiOtelMiddleware(
    tracerProvider.getTracer("tanstack-ai"),
    { threadIdAsSession: Boolean(threadId) },
  );
  // When the client disconnects, abort chat() so its spans end as cancelled
  // and export. Without this chat() never learns the client left.
  const abortController = new AbortController();
  const abort = () => abortController.abort();
  signal?.addEventListener("abort", abort, { once: true });
  if (signal?.aborted) abort();
  try {
    let text = "";
    let runError = null;
    for await (const chunk of chat({
      adapter: openaiChatCompletions(process.env.OPENAI_MODEL ?? "gpt-4o-mini"),
      messages: [{ role: "user", content: question }],
      tools: [getWeather],
      middleware: [middleware],
      abortController,
      ...(threadId ? { threadId } : {}),
    })) {
      if (chunk.type === "RUN_ERROR") runError ??= chunk;
      else if (chunk.type === "TEXT_MESSAGE_CONTENT") text += chunk.delta ?? "";
    }
    if (runError) throw new Error(runError.message);
    return text;
  } finally {
    signal?.removeEventListener("abort", abort);
    await flushTraces(tracerProvider); // waits at most 2 s, never throws
  }
}

Never stop reading the stream with a break, an early return or a dropped response body: TanStack ends its spans only when the run finishes, fails or is aborted, so the whole trace would be lost. Abort the abortController instead. When you stream to the client with toServerSentEventsResponse(stream, { abortController }), pass it the same controller you passed to chat().

In a long-lived server you can leave out the per-request flush (register()‘s default SimpleSpanProcessor exports each span when it ends) and call shutdownTraces(tracerProvider) when the process stops. Keep the flush on serverless platforms, where the process can be frozen after the response.

5. Run and check

Call the route once with a question that makes the model use a tool, then open the project in Observe. A chat that called a tool looks like this:

SpanType in Future AGIWhat it carries
chat <model>agent (more than one model call) or chainRequest model, provider, tanstack.ai.iterations, finish reason, session.id when you pass a conversation id
chat <model> #<n>llmRequest and response model, provider, gen_ai.usage.* tokens (cache and reasoning tokens when the provider sends them), finish reason, tanstack.ai.iteration; Future AGI computes cost from model and tokens
execute_tool <tool>toolTool name, call id, type, tanstack.ai.tool.outcome, error.type and ERROR status when the tool throws

A failed model call (a wrong model name, a provider outage) is an ERROR on the chat <model> #<n> span and on the root, with the provider’s error code (400, model_not_found) as error.type and status message. A tool that throws is an ERROR on its own span only: TanStack sends the error to the model as the tool result, so the run can still finish. A client disconnect ends every open span as ERROR cancelled with tanstack.ai.completion.reason=cancelled.

What is captured

DataDefaultcaptureContent: true
Span names, timing, parent links, status codeYesYes
Request and response model, gen_ai.system (adapter name), gen_ai.provider.name, finish reason, sampling parameters you setYesYes
Tokens per model call (gen_ai.usage.input_tokens, output_tokens, total_tokens, cache read and creation, reasoning)YesYes
Tool name, call id, type, outcomeYesYes
tanstack.ai.iteration, tanstack.ai.iterations, tanstack.ai.completion.reason, tanstack.ai.root_usage.*YesYes
session.id (the threadId you pass, with threadIdAsSession: true)YesYes
error.type, and an exception event with only exception.typeYesYes
System prompt, messages and the answer (gen_ai.input.messages, gen_ai.output.messages, message events)NoYes, through redact
Tool arguments and resultsNoYes, through redact
Error text (span status message, exception.message)NoYes, through redact
Stack traces (exception.stacktrace)NoNo
Resource attributesproject_name, project_type, project_version_name, project_version_id, eval_tags, metadata and service.name. No host, process or user attributes.The same
TanStack metrics (gen_ai.client.token.usage, operation duration)Not created: the recipe passes no meterThe same

Privacy

Default: metadata only. TanStack’s captureContent option defaults to false, and the recipe leaves it there, so no system prompt, message, answer, tool argument or tool result reaches a span. TanStack does record errors whatever captureContent is: it calls span.recordException(error) and sets the error’s message as the span status. The message comes from the provider or from your tool, and the exception event carries a stack trace with local file paths and your user name. The recipe gives otelMiddleware a tracer whose spans replace both calls (public OpenTelemetry API only): the span gets error.type, which is the provider’s error code or the error class name, and the status message and exception event carry only that type. A stack trace is never exported.

Opt in: captureContent: true. Pass it to futureAgiOtelMiddleware. TanStack then exports messages and tool payloads, and the recipe exports the error message too, still without a stack trace. Pass redact to rewrite every text first; a redactor that throws yields [redaction_failed], never the raw text:

const middleware = futureAgiOtelMiddleware(tracer, {
  captureContent: true,
  redact: (text) => text.replace(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, "[email]"),
});

Ids you choose. The threadId you pass as the session is exported verbatim on every span. Use an opaque id (a random UUID per conversation), never an email address or user name. The same goes for TanStack’s legacy conversationId.

Apps that already run OpenTelemetry

TanStack starts its chat span under whatever span is active. If your app has its own OpenTelemetry provider and context manager (HTTP server instrumentation, a framework’s built-in tracing), that is your request span, which goes to your other backend. Future AGI would then receive a trace whose root has a parent it never sees, and the trace would be missing from the Tracing list. The recipe therefore starts every chat in a new trace. If your app’s own spans also go to Future AGI and you want the chat nested under them, pass joinActiveTrace: true.

registerFutureAgiTracing() calls register() with setGlobalTracerProvider: false, so it never replaces your app’s global provider or sends your other spans to Future AGI.

How it behaves

  • Tokens are counted once. TanStack also writes the sum of every model call’s usage onto the chat span. Future AGI sums gen_ai.usage.* over every span of a trace for the trace view, sessions, dashboards and cost, so the recipe moves the chat span’s copy to tanstack.ai.root_usage.* whenever the model-call spans carry usage. If no model call reported usage, the chat span keeps it.
  • Sessions. With threadIdAsSession: true and a threadId on chat(), every span carries session.id and Future AGI groups the conversation. Without a threadId, TanStack generates thread-<ms>-<random> per call, so the recipe sets no session.
  • Exporter down. With nothing listening at FI_BASE_URL, chat() and the route still answer; flushTraces() logs [futureagi] span export failed and never throws. A collector that accepts but never answers costs the request at most the 2 s flush bound.
  • Batching. register({ batch: true }) has no effect with @traceai/fi-core 1.0.0 on OpenTelemetry SDK 2.x, so the recipe uses the default per-span export. With a very slow collector the OTLP exporter’s limit of 30 exports in flight can drop spans (about 8 concurrent chats of 4 spans).
  • Global provider untouched. See Apps that already run OpenTelemetry.

Limitations

  • The Tracing list shows no tokens for a TanStack trace. The list reads tokens from the root span, and the recipe moves the root’s sum off it so the trace view, sessions, dashboards and cost count each call once. Open the trace, or look at the LLM spans, for tokens and cost.
  • Content columns reflect captureContent. The default metadata-only mode emits no messages. With captureContent: true, Future AGI 1.56.1 promotes gen_ai.input.messages and gen_ai.output.messages into the input/output columns and retains the raw attributes.
  • Do not use chat({ stream: false }). In 0.64.0 to 0.69.0 the non-streaming path stops reading at the first RUN_ERROR, so a failed call never reaches TanStack’s error hook and leaves no trace at all. Read the stream as in step 4.
  • Media activities (generateImage, speech, transcription) get a span from otelMiddleware, but the recipe sets no Future AGI kind on it. This guide covers server-side chat calls.
  • Browser and edge are out of scope: the recipe is for Node.js servers, and FI_API_KEY must never reach a client bundle.
  • Version compatibility. This recipe is pinned to @tanstack/ai 0.69.0 and @tanstack/ai-openai 0.30.0. Do not upgrade the pins without checking the middleware and SDK compatibility.

Troubleshooting

  • No traces in the project. Check FI_API_KEY, FI_SECRET_KEY and FI_PROJECT_NAME, and that FI_BASE_URL is an origin without a path. The collector drops a batch without project_name.
  • Cannot find module '@tanstack/ai/middlewares/otel' or otelMiddleware is not exported. Import it from the @tanstack/ai/middlewares/otel subpath; @tanstack/ai does not re-export it.
  • Traces open from a link but are missing from the Tracing list. Your chat span has a parent from another backend. Use the recipe’s default (do not pass joinActiveTrace: true) or send that app’s spans to Future AGI too.
  • A failed request leaves no trace. The route used stream: false, or stopped reading the stream without aborting. See step 4.
  • The trace view shows twice the tokens. A bare otelMiddleware is registered instead of, or next to, futureAgiOtelMiddleware, or the SDK is not on the pinned version. Keep one middleware and the overrides.
  • Duplicate spans. Two middlewares (for example the OpenInference TanStack middleware and this recipe) are registered. Keep one.
  • Prompts or error text appear on spans. captureContent: true is set. Remove it, or add a redact.
  • The route waits about 2 s and logs span export still pending. The collector is not answering. Spans keep exporting in the background.

Next Steps

Was this page helpful?

Questions & Discussion