Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

Tracing (OpenTelemetry)

Spiceflow has built-in OpenTelemetry tracing. Pass a tracer to the constructor and every request gets automatic spans for middleware, handlers, loaders, layouts, pages, and RSC serialization — no monkey-patching, no plugins.

Setup

Install the OTel SDK packages alongside spiceflow:
bash
npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/api
Create a tracing setup file that runs before your app starts. This registers the OTel SDK globally so spans are collected and exported:
ts
// tracing.ts import { NodeSDK } from '@opentelemetry/sdk-node' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' const sdk = new NodeSDK({ serviceName: 'my-app', traceExporter: new OTLPTraceExporter({ // Send traces to your collector or observability backend url: 'http://localhost:4318/v1/traces', }), }) sdk.start()
Then pass a tracer to your Spiceflow app:
ts
// main.ts import './tracing' // must be imported first import { trace } from '@opentelemetry/api' import { Spiceflow } from 'spiceflow' export const app = new Spiceflow({ tracer: trace.getTracer('my-app') }).get( '/api/users/:id', ({ params }) => { return { id: params.id, name: 'Alice' } }, )
Server timing is enabled by default, so spans are automatically visible in Chrome DevTools:
ts
export const app = new Spiceflow({ tracer: trace.getTracer('my-app'), }).get('/api/users/:id', ({ params, tracer }) => { return tracer.startActiveSpan('db.query', (span) => { span.end() return { id: params.id, name: 'Alice' } }) })

What you get

Every request produces a span tree. For API routes:
bash
GET /api/users/:id [server] ├── middleware - cors ├── middleware - auth └── handler - /api/users/:id
For React routes with loaders and layouts:
bash
GET /dashboard [server] ├── middleware - auth ├── loader - /dashboard ├── loader - /sidebar ├── layout - / ├── page - /dashboard └── rsc.serialize
Each span includes standard HTTP attributes (http.request.method, http.route, http.response.status_code, url.full) following OTel semantic conventions. Errors are recorded with recordException and set the span status to ERROR. If your errors use errore tagged errors, the stable fingerprint is propagated as an error.fingerprint attribute for consistent error grouping.

Server Timing

When a tracer is provided, spiceflow automatically emits a Server-Timing response header on every request. This makes slow work visible in Chrome DevTools without setting up a trace backend first. Set serverTiming: false to disable it.
Spiceflow keeps the browser format flat, because Server-Timing is flat, but the desc value preserves the nested path with >. Child items omit the repeated root request prefix so the list stays easy to scan:
http
Server-Timing: get-users-id;dur=42.1;desc="GET /users/:id" Server-Timing: handler-users-id;dur=40.3;desc="handler - /users/:id" Server-Timing: handler-users-id-db.query;dur=31.8;desc="handler - /users/:id > db.query"
Built-in spans and custom spans created with context.tracer.startActiveSpan() are both included. This is useful for debugging slow database queries, cache lookups, or external API calls directly in the browser.

Custom spans and attributes

Every handler receives span and tracer on its context. These work whether or not you configured a tracer — when no tracer is passed, they use no-op implementations that do nothing, so you never need conditional checks.
Add attributes to the current span:
ts
.get('/api/users/:id', ({ params, span }) => { const user = db.findUser(params.id) span.setAttribute('user.plan', user.plan) return user })
Read the current trace id or span id:
ts
.get('/api/users/:id', ({ span }) => { const traceId = span.spanContext?.()?.traceId const spanId = span.spanContext?.()?.spanId console.log({ traceId, spanId }) return { ok: true } })
spanContext() is optional because Spiceflow keeps the span interface compatible with simple custom tracer test doubles. When no tracer is configured, the noop span returns undefined.
Record a caught exception without re-throwing:
ts
.post('/api/webhook', async ({ request, span }) => { const body = await request.json() try { await processWebhook(body) } catch (err) { span.recordException(err) } return { ok: true } })
Create child spans for DB calls or external APIs:
ts
.get('/api/data', async ({ tracer, params }) => { return tracer.startActiveSpan('db.query', async (dbSpan) => { const data = await db.query(params.id) dbSpan.setAttribute('db.rows', data.length) dbSpan.end() return data }) })
You can also import withSpan as a convenience wrapper that handles errors and span.end() automatically:
ts
import { withSpan } from 'spiceflow' .get('/api/data', async ({ tracer, params }) => { return withSpan(tracer, 'db.query', {}, async (dbSpan) => { dbSpan.setAttribute('db.table', 'users') return db.query(params.id) }) })

Context propagation to libraries

Libraries that use OpenTelemetry (like the Vercel AI SDK, database drivers, HTTP clients) automatically create child spans under your request span — no extra wiring needed.
This works because the OTel NodeSDK registers an AsyncLocalStorageContextManager by default. When spiceflow calls tracer.startActiveSpan() for a request, the root span is stored in AsyncLocalStorage. Any library that calls trace.getTracer() from @opentelemetry/api inside your handler sees the active span and creates children, not roots.
bash
GET /api/chat [server] ├── middleware - auth ├── handler - /api/chat │ ├── ai.generateText ← created by AI SDK │ │ ├── ai.toolCall ← created by AI SDK │ │ └── ai.toolCall │ └── db.query ← created by your code
Example with the AI SDK:
ts
import { generateText } from 'ai' import { openai } from '@ai-sdk/openai' .post('/api/chat', async ({ request }) => { const { prompt } = await request.json() const result = await generateText({ model: openai('gpt-4.1'), prompt, experimental_telemetry: { isEnabled: true }, }) return { text: result.text } })
The ai.generateText and ai.toolCall spans appear as children of handler - /api/chat automatically. This applies to any OTel-instrumented library — HTTP clients, database drivers, queue publishers, etc.

Sentry

Sentry's Node.js SDK is built on OpenTelemetry, so all spiceflow spans flow to Sentry automatically. Initialize Sentry before creating your app, then pass the tracer from the Sentry client:
ts
import * as Sentry from '@sentry/node' import { Spiceflow } from 'spiceflow' Sentry.init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', tracesSampleRate: 1.0, }) export const app = new Spiceflow({ tracer: Sentry.getClient()!.tracer, }).get('/api/users/:id', ({ params }) => { return { id: params.id, name: 'Alice' } })
Every request span, middleware span, handler span, and custom child span created with context.tracer.startActiveSpan() shows up in Sentry's Performance dashboard. Errors recorded with span.recordException() are captured as Sentry issues with the full trace context attached.
If you already use the generic OTel setup from the section above, Sentry picks up those spans too. The difference is that with Sentry.getClient()!.tracer you skip installing @opentelemetry/api as a direct dependency.

Cloudflare Workers

On Cloudflare Workers, tracing is automatic. Spiceflow detects the Cloudflare runtime and uses the native tracing.enterSpan() API without any setup. Just enable tracing in your wrangler.jsonc:
jsonc
{ "observability": { "traces": { "enabled": true } } }
All spiceflow spans (middleware, handlers, loaders, layouts, RSC serialization) appear alongside Cloudflare's automatic platform spans (KV, D1, fetch) in the dashboard. If you pass an explicit tracer, it takes priority over the automatic one.
See the Cloudflare page for details and limitations.

Zero overhead without tracer

When no tracer is passed (and the runtime is not Cloudflare), every instrumentation point is skipped entirely — no strings allocated, no objects created, no extra async wrappers. The span and tracer on the handler context use no-op implementations whose empty methods V8 inlines away.