All PostsFeb 28, 2026ShipAI Team1 min read

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.

ObservabilityOpenTelemetryNext.jsOperations

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/api

2. 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-traces

Axiom 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 desc

Error 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_ENDPOINT set.
  • Make a request to an instrumented route.
  • Open Axiom → verify traces appear with the correct service name.
  • Trigger an AI call → verify ai.total_tokens appears on the span.
  • Force an error → verify the span status is ERROR and 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

Ready to ship?

Stop rebuilding auth and billing from scratch.

ShipAI.today gives you a production-ready Next.js foundation. Every module pre-integrated — spend your time building your product, not plumbing.

Full source code · Commercial license · Lifetime updates