OpenTelemetry in Next.js: Tracing AI and SaaS Operations
How to instrument a Next.js SaaS with OpenTelemetry to trace AI calls, database queries, and request latency—with Axiom as the backend and real-world patterns for production debugging.
Contents
Most Next.js SaaS apps run blind in production. Errors surface from Sentry, but latency spikes, slow AI calls, and cascading database query problems are invisible until a user complains. OpenTelemetry changes this by giving you distributed traces across every request.
This guide covers instrumenting a Next.js app with OpenTelemetry, sending data to Axiom, and the specific trace patterns that matter for AI SaaS: LLM call duration, database query cost, and token usage per request.
What OpenTelemetry Gives You
OpenTelemetry is a vendor-neutral observability standard. In a Next.js SaaS context, it lets you:
- Trace a request end-to-end: From the edge middleware to the database query to the LLM call and back.
- Identify slow spans: Find which specific function is adding 2 seconds to your P95 latency.
- Correlate AI costs with features: Know that feature X uses 3x more tokens than feature Y.
- Debug production failures: Replay the exact sequence of calls for a failing request.
Without this, your debugging process is: add logs, redeploy, reproduce, grep. With traces, you query the trace directly.
Architecture
Next.js App
├── instrumentation.ts # OTel initialization (runs once at startup)
├── app/
│ └── api/
│ └── ai/route.ts # Route handler (auto-traced by @vercel/otel)
└── lib/
├── db.ts # Drizzle (traced via pg instrumentation)
└── ai.ts # AI calls (custom spans)
↓ OTLP/HTTP export
Axiom (or any OTLP-compatible backend)Setup
1. Install Dependencies
bun add @vercel/otel @opentelemetry/api2. Create instrumentation.ts
This file is special in Next.js—it runs once when the server starts, before any request handlers.
// src/instrumentation.ts
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel({
serviceName: "shipai-app",
// Axiom as OTLP backend
traceExporter: "otlp",
});
}3. Configure Environment Variables
# .env.local
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.axiom.co/v1/traces
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer <your-axiom-token>,X-Axiom-Dataset=nextjs-tracesAxiom free tier
Axiom offers 500GB/month of data ingest on the free tier. More than enough for a SaaS at early scale. Other compatible backends: Honeycomb, Jaeger, Grafana Tempo, or your own OTLP collector.
4. Enable Next.js Instrumentation Hook
Ensure instrumentationHook is enabled in your Next.js config:
// next.config.ts
const nextConfig = {
experimental: {
instrumentationHook: true,
},
};
export default nextConfig;Next.js 15+
In Next.js 15+, instrumentationHook is enabled by default and the experimental flag is no longer needed. Check your version before adding it.
Adding Custom Spans
Auto-instrumentation traces HTTP requests and some Node.js built-ins. For AI calls and business logic, add custom spans:
// src/lib/ai.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
const tracer = trace.getTracer("ai-service");
export async function callLLM(prompt: string, userId: string) {
return tracer.startActiveSpan("llm.generate", async (span) => {
span.setAttributes({
"ai.model": "gpt-4o-mini",
"ai.user_id": userId,
"ai.prompt_length": prompt.length,
});
try {
const { text, usage } = await generateText({
model: openai("gpt-4o-mini"),
prompt,
});
span.setAttributes({
"ai.input_tokens": usage.promptTokens,
"ai.output_tokens": usage.completionTokens,
"ai.total_tokens": usage.totalTokens,
"ai.estimated_cost_usd": calculateCost(usage),
});
span.setStatus({ code: SpanStatusCode.OK });
return text;
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : "Unknown error",
});
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}
function calculateCost(usage: { promptTokens: number; completionTokens: number }) {
// gpt-4o-mini pricing: $0.15/1M input, $0.60/1M output
const inputCost = (usage.promptTokens / 1_000_000) * 0.15;
const outputCost = (usage.completionTokens / 1_000_000) * 0.60;
return inputCost + outputCost;
}Tracing Database Queries with Drizzle
Drizzle doesn't have native OTel support, but you can wrap queries manually for critical paths:
// src/lib/db-traced.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
import { db } from "@/lib/db";
const tracer = trace.getTracer("database");
export async function tracedQuery<T>(
name: string,
query: () => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(`db.${name}`, async (span) => {
span.setAttribute("db.system", "postgresql");
span.setAttribute("db.operation", name);
try {
const result = await query();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR });
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}
// Usage:
// const user = await tracedQuery("findUser", () =>
// db.query.users.findFirst({ where: eq(users.id, userId) })
// );What to Trace in an AI SaaS
Not every function needs a span. Focus on:
High-value traces:
- Every LLM API call (duration, model, tokens, cost).
- Database queries over 50ms.
- Full request lifecycle for routes where users report slowness.
- Webhook processing duration (Stripe events, etc.).
Low-value traces (skip or sample):
- Static asset serving.
- Health check endpoints.
- Trivial reads on cached data.
Querying Traces in Axiom
Once data is flowing to Axiom, useful queries:
P95 latency for AI route:
['nextjs-traces']
| where ['span.name'] == "POST /api/ai/chat"
| summarize percentile(['duration'], 95) by bin_auto(_time)Average tokens per user:
['nextjs-traces']
| where ['ai.model'] != ""
| summarize avg(['ai.total_tokens']) by ['ai.user_id']
| order by avg_ai_total_tokens descError rate by route:
['nextjs-traces']
| where ['otel.status_code'] == "ERROR"
| summarize count() by ['span.name'], bin_auto(_time)Tradeoffs
Axiom vs. self-hosted Jaeger
Axiom's free tier is generous and setup is 10 minutes. Jaeger on a VPS gives you full control and zero per-event cost but requires maintenance and storage management. For early SaaS, Axiom wins on time. For high-volume production with cost sensitivity, evaluate self-hosting.
Full tracing vs. sampling
Tracing 100% of requests at high volume is expensive. At 10k+ requests/day, consider sampling: trace 10% of successful requests but 100% of errors. The @vercel/otel SDK supports configurable sampling rates.
@vercel/otel vs. raw @opentelemetry/sdk-node
@vercel/otel is a higher-level wrapper optimized for Next.js. It handles the setup complexity and works on both Node.js and Edge runtimes. Raw @opentelemetry/sdk-node gives you more control but requires more configuration. Start with @vercel/otel.
Verification
- Run your app locally with
OTEL_EXPORTER_OTLP_ENDPOINTset. - Make a request to an instrumented route.
- Open Axiom → verify traces appear with the correct service name.
- Trigger an AI call → verify
ai.total_tokensappears on the span. - Force an error → verify the span status is
ERRORand the exception is recorded. - Check that P95 latency for your main route is within acceptable bounds (aim for < 2s for AI routes with streaming).
Next Steps
- ShipAI Production Playbook — broader production hardening including logging and error alerting.
- How to Build an AI SaaS with Next.js — complete architecture guide.
- ShipAI includes OpenTelemetry instrumentation pre-wired with Axiom-compatible export and admin traces viewer out of the box.