Skip to main content

SaaS Observability Stack 2026: Logs, Metrics, Traces, and Error Tracking

·StarterPick Team
observabilitysentryopentelemetryaxiommonitoringsaas2026

TL;DR

Most SaaS boilerplates ship no observability. That's a production landmine. When your app breaks at 2am with 500 errors, you need logs to tell you what happened, traces to tell you where it happened, and error alerts to tell you it happened at all. In 2026, the practical stack is: Sentry for errors (free tier handles most SaaS), OpenTelemetry for traces (vendor-agnostic), and Axiom or BetterStack for structured logs ($0 to start). Total cost: $0/month until you're at meaningful scale.

Key Takeaways

  • Sentry: error tracking, performance monitoring, session replay — free tier generous (5K errors/month)
  • OpenTelemetry: standard tracing that works with any backend — instrument once, switch vendors freely
  • Axiom: structured log ingestion, $0 free tier (1GB/day), incredible query performance
  • BetterStack: logs + uptime monitoring + status page in one tool, $0 free tier
  • What boilerplates miss: no error boundary setup, no structured logging, no trace context propagation
  • First 3 things to add: Sentry error tracking, structured logs on API routes, uptime monitoring

Step 1: Sentry — Error Tracking and Performance

Start here. Sentry catches unhandled errors, performance regressions, and session replays.

npx @sentry/wizard@latest -i nextjs
# Wizard sets up sentry.client.config.ts, sentry.server.config.ts,
# sentry.edge.config.ts, and instruments your Next.js app automatically
// sentry.server.config.ts
import * as Sentry from '@sentry/nextjs';

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,

  // Trace 10% of requests in production (100% in dev):
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,

  // Profile 10% of sampled requests:
  profilesSampleRate: 0.1,

  // Ignore common noise:
  ignoreErrors: [
    'ResizeObserver loop limit exceeded',
    'Non-Error promise rejection captured',
  ],
});
// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs';

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,

  // Session replay — watch what users did before the error:
  replaysSessionSampleRate: 0.01,      // 1% of sessions
  replaysOnErrorSampleRate: 1.0,       // 100% of sessions with errors

  integrations: [
    Sentry.replayIntegration({
      maskAllText: true,         // GDPR-friendly
      blockAllMedia: false,
    }),
  ],
});

Capturing Context with Sentry

// Add user context to errors (helps debugging):
import * as Sentry from '@sentry/nextjs';

export async function setUserContext(userId: string, email: string) {
  Sentry.setUser({ id: userId, email });
}

// In your auth middleware or session handler:
const session = await auth();
if (session?.user) {
  setUserContext(session.user.id, session.user.email!);
}
// Capture custom errors with context:
try {
  await processPayment(userId, amount);
} catch (error) {
  Sentry.captureException(error, {
    tags: { operation: 'payment', userId },
    extra: { amount, currency: 'usd' },
    level: 'error',
  });
  throw error;
}
// Custom performance transaction:
const transaction = Sentry.startTransaction({
  name: 'AI Document Processing',
  op: 'ai.pipeline',
});

try {
  const span1 = transaction.startChild({ op: 'ai.extract', description: 'Extract text' });
  const text = await extractText(document);
  span1.finish();

  const span2 = transaction.startChild({ op: 'ai.embed', description: 'Generate embeddings' });
  const embeddings = await generateEmbeddings(text);
  span2.finish();

  transaction.setData('chunkCount', embeddings.length);
} finally {
  transaction.finish();
}

Error Boundary for React

// components/error-boundary.tsx — catches rendering errors:
'use client';
import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';

export default function ErrorBoundary({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Report to Sentry with context:
    Sentry.captureException(error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
      <h2 className="text-lg font-semibold">Something went wrong</h2>
      <p className="text-sm text-gray-500">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm"
      >
        Try again
      </button>
    </div>
  );
}

Step 2: Structured Logging

Stop using console.log. Use structured JSON logs you can query.

// lib/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface LogEntry {
  level: LogLevel;
  message: string;
  timestamp: string;
  [key: string]: unknown;
}

export const logger = {
  debug: (message: string, context?: Record<string, unknown>) =>
    log('debug', message, context),
  info: (message: string, context?: Record<string, unknown>) =>
    log('info', message, context),
  warn: (message: string, context?: Record<string, unknown>) =>
    log('warn', message, context),
  error: (message: string, context?: Record<string, unknown>) =>
    log('error', message, context),
};

function log(level: LogLevel, message: string, context?: Record<string, unknown>) {
  const entry: LogEntry = {
    level,
    message,
    timestamp: new Date().toISOString(),
    env: process.env.NODE_ENV,
    ...context,
  };

  // JSON to stdout — Axiom/BetterStack/any log aggregator picks this up:
  if (level === 'error') {
    console.error(JSON.stringify(entry));
  } else {
    console.log(JSON.stringify(entry));
  }
}
// Usage in API routes:
import { logger } from '@/lib/logger';

export async function POST(req: Request) {
  const session = await auth();

  logger.info('Checkout initiated', {
    userId: session?.user.id,
    priceId: body.priceId,
  });

  try {
    const checkoutSession = await stripe.checkout.sessions.create({ ... });
    logger.info('Checkout session created', {
      userId: session?.user.id,
      sessionId: checkoutSession.id,
    });
    return Response.json({ url: checkoutSession.url });
  } catch (error) {
    logger.error('Checkout session failed', {
      userId: session?.user.id,
      error: (error as Error).message,
    });
    throw error;
  }
}

Axiom for Log Storage

npm install next-axiom
// next.config.ts — wrap with Axiom:
import { withAxiom } from 'next-axiom';

export default withAxiom({
  // your Next.js config
});
// lib/logger.ts — use Axiom logger in App Router:
import { Logger } from 'next-axiom';

export const log = new Logger();

// In server components and route handlers:
log.info('User signed up', { userId, plan: 'free' });
log.error('Payment failed', { userId, error: error.message });

// Flush at end of request (required in edge/serverless):
await log.flush();

Axiom free tier: 1GB/day ingestion, 30-day retention — plenty for early-stage SaaS.


Step 3: OpenTelemetry Tracing

OpenTelemetry traces distributed requests across your services — see exactly where time is spent.

npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
// instrumentation.ts — Next.js 15 instrumentation hook
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { NodeSDK } = await import('@opentelemetry/sdk-node');
    const { getNodeAutoInstrumentations } = await import(
      '@opentelemetry/auto-instrumentations-node'
    );
    const { OTLPTraceExporter } = await import(
      '@opentelemetry/exporter-trace-otlp-http'
    );

    const sdk = new NodeSDK({
      traceExporter: new OTLPTraceExporter({
        // Send to your preferred backend:
        // Axiom: https://api.axiom.co/v1/traces
        // Grafana Cloud: https://<your-instance>.tempo.grafana.net/otlp
        // Jaeger: http://localhost:4318/v1/traces
        url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT!,
        headers: {
          Authorization: `Bearer ${process.env.OTEL_API_KEY}`,
          'X-Axiom-Dataset': process.env.AXIOM_DATASET!,
        },
      }),
      instrumentations: [
        getNodeAutoInstrumentations({
          '@opentelemetry/instrumentation-http': { enabled: true },
          '@opentelemetry/instrumentation-pg': { enabled: true },
        }),
      ],
    });

    sdk.start();
  }
}
// Add custom spans to your code:
import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('my-saas');

export async function processAiDocument(documentId: string) {
  return tracer.startActiveSpan('ai-document-processing', async (span) => {
    span.setAttribute('document.id', documentId);

    try {
      const embeddingSpan = tracer.startSpan('generate-embeddings');
      const embeddings = await generateEmbeddings(text);
      embeddingSpan.setAttribute('embedding.count', embeddings.length);
      embeddingSpan.end();

      span.setAttribute('status', 'success');
      return embeddings;
    } catch (error) {
      span.recordException(error as Error);
      span.setStatus({ code: 2, message: (error as Error).message }); // ERROR
      throw error;
    } finally {
      span.end();
    }
  });
}

Step 4: Uptime Monitoring

Know when your app is down before users report it:

// BetterStack (formerly Logtail) — free tier includes uptime monitoring:
// 1. Create a monitor at betterstack.com pointing to https://yourapp.com/api/health
// 2. Add a health endpoint:

export async function GET() {
  try {
    // Check critical dependencies:
    await db.$queryRaw`SELECT 1`;

    return Response.json({
      status: 'ok',
      timestamp: new Date().toISOString(),
      version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'dev',
    });
  } catch (error) {
    // Returns 500 — uptime monitor triggers alert
    return Response.json(
      { status: 'error', error: (error as Error).message },
      { status: 500 }
    );
  }
}

Pre-launch / MVP:
  → Sentry free (5K errors/month)
  → Axiom free (1GB/day logs)
  → BetterStack free (uptime monitoring)
  → Cost: $0/month

Growing (1K-10K users):
  → Sentry Team ($26/month — higher limits, team features)
  → Axiom personal ($25/month — 50GB/month)
  → BetterStack Starter ($25/month — more monitors)
  → Total: ~$75/month

Scale (10K+ users):
  → Sentry Business ($80/month)
  → Self-hosted Grafana Stack on Fly.io (~$30/month)
    → Grafana (dashboards), Loki (logs), Tempo (traces), Prometheus (metrics)
  → Total: ~$110/month + infra

Find boilerplates with pre-configured observability at StarterPick.

Comments