tracer to the constructor and every request gets automatic spans for middleware, handlers, loaders, layouts, pages, and RSC serialization — no monkey-patching, no plugins.1npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/api
12345678910111213// 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()
1234567891011// 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' } }, )
12345678export 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' } }) })
1234GET /api/users/:id [server] ├── middleware - cors ├── middleware - auth └── handler - /api/users/:id
1234567GET /dashboard [server] ├── middleware - auth ├── loader - /dashboard ├── loader - /sidebar ├── layout - / ├── page - /dashboard └── rsc.serialize
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.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.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:123Server-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"
context.tracer.startActiveSpan() are both included. This is useful for debugging slow database queries, cache lookups, or external API calls directly in the browser.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.12345.get('/api/users/:id', ({ params, span }) => { const user = db.findUser(params.id) span.setAttribute('user.plan', user.plan) return user })
123456.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.123456789.post('/api/webhook', async ({ request, span }) => { const body = await request.json() try { await processWebhook(body) } catch (err) { span.recordException(err) } return { ok: true } })
12345678.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 }) })
withSpan as a convenience wrapper that handles errors and span.end() automatically:12345678import { 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) }) })
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.1234567GET /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
123456789101112import { 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 } })
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.12345678910111213import * 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' } })
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.Sentry.getClient()!.tracer you skip installing @opentelemetry/api as a direct dependency.tracing.enterSpan() API without any setup. Just enable tracing in your wrangler.jsonc:1234567{ "observability": { "traces": { "enabled": true } } }
tracer, it takes priority over the automatic one.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.