Tutorials
|stacknotice.com
16 min left|
0%
|3,200 words
Tutorials

Observability from Day 1: Sentry, PostHog, and Structured Logs (2026)

How senior devs set up error tracking, product analytics, and structured logging in Next.js before they need them. SaaS Series #5.

May 26, 202616 min read
Share:
Observability from Day 1: Sentry, PostHog, and Structured Logs (2026)

Most teams add observability after the first production incident. By then they're flying blind — no error context, no timeline, no way to answer "did this affect all users or just one?" This guide sets it up before you need it.

This is SaaS Series #5:


What "observability" actually means

Three pillars, three tools:

PillarWhat it answersTool
Error trackingWhat broke, where, for whomSentry
Product analyticsWhat users do, which features they usePostHog
Structured logsWhat your app did, in searchable JSONPino + Axiom

console.log doesn't count as any of these. It's not searchable, not persistent beyond the process lifetime in serverless, and not correlated across requests.


Sentry: error tracking that actually helps

Sentry's free tier gives you 5,000 errors/month and 10,000 performance transactions — enough for any early SaaS.

Installation

npx @sentry/wizard@latest -i nextjs

The wizard creates sentry.client.config.ts, sentry.server.config.ts, and sentry.edge.config.ts. Accept the defaults for route instrumentation.

Configuration

// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs'
 
Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
 
  // Sample 10% of transactions in production — adjust based on volume
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
 
  // Session replay for debugging UI bugs
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0, // 100% for errors
 
  integrations: [
    Sentry.replayIntegration({
      maskAllText: true,      // GDPR: mask PII in replays
      blockAllMedia: false,
    }),
  ],
 
  // Don't send errors from localhost
  enabled: process.env.NODE_ENV === 'production',
})
// sentry.server.config.ts
import * as Sentry from '@sentry/nextjs'
 
Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
  enabled: process.env.NODE_ENV === 'production',
})

Adding user context to errors

Without user context, you get "TypeError: Cannot read properties of undefined" with no idea who was affected or how to reproduce it.

// lib/sentry.ts
import * as Sentry from '@sentry/nextjs'
 
export function identifyUserInSentry(user: {
  id: string
  email: string
  plan?: string
}) {
  Sentry.setUser({
    id: user.id,
    email: user.email,
    // Custom data visible in Sentry UI
    plan: user.plan,
  })
}
 
export function clearSentryUser() {
  Sentry.setUser(null)
}

Call this after authentication:

// app/layout.tsx (or a client component that runs after auth)
'use client'
import { useUser } from '@clerk/nextjs'
import { useEffect } from 'react'
import { identifyUserInSentry } from '@/lib/sentry'
 
export function SentryUserIdentifier() {
  const { user } = useUser()
 
  useEffect(() => {
    if (user) {
      identifyUserInSentry({
        id: user.id,
        email: user.primaryEmailAddress?.emailAddress ?? '',
        plan: user.publicMetadata.plan as string,
      })
    }
  }, [user])
 
  return null
}

Capturing custom errors with context

Don't rely only on automatic error capture. Add context when you catch expected errors:

// app/api/generate/route.ts
import * as Sentry from '@sentry/nextjs'
 
export async function POST(req: Request) {
  const { userId } = await auth()
 
  try {
    const result = await callAIApi(prompt)
    return Response.json(result)
  } catch (err) {
    // Add business context before sending to Sentry
    Sentry.captureException(err, {
      tags: {
        feature: 'ai-generation',
        userId,
      },
      extra: {
        promptLength: prompt.length,
        model: 'claude-sonnet-4-6',
      },
    })
 
    return Response.json(
      { error: 'Generation failed' },
      { status: 500 }
    )
  }
}

Custom error boundary for React components

// components/ErrorBoundary.tsx
'use client'
import * as Sentry from '@sentry/nextjs'
import { Component, ReactNode } from 'react'
 
interface Props {
  children: ReactNode
  fallback?: ReactNode
}
 
interface State {
  hasError: boolean
  eventId?: string
}
 
export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { hasError: false }
  }
 
  static getDerivedStateFromError() {
    return { hasError: true }
  }
 
  componentDidCatch(error: Error, info: React.ErrorInfo) {
    const eventId = Sentry.captureException(error, {
      extra: { componentStack: info.componentStack },
    })
    this.setState({ eventId })
  }
 
  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback ?? (
          <div>
            <p>Something went wrong.</p>
            <button
              onClick={() =>
                Sentry.showReportDialog({ eventId: this.state.eventId })
              }
            >
              Report feedback
            </button>
          </div>
        )
      )
    }
 
    return this.props.children
  }
}
Sentry alerts that don't cause alert fatigue

Configure Sentry to alert on: new error types (not seen before), error rate spikes (>10x baseline in 5 min), and performance regressions (p95 latency >2s). Avoid alerting on every single error occurrence.


PostHog: product analytics that respects privacy

PostHog is open source, self-hostable, and ships 1M events/month free on their cloud. It covers analytics, feature flags, session replay, and A/B testing — all in one tool.

See the feature flags guide for the full PostHog feature flags setup. Here we focus on analytics.

Installation

npm install posthog-js posthog-node

Client-side provider

// providers/PostHogProvider.tsx
'use client'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { useEffect } from 'react'
 
export function PostHogProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
      api_host: '/ingest', // proxy through Next.js to avoid ad blockers
      ui_host: 'https://us.posthog.com',
      capture_pageview: false, // handle manually for SPA routing
      capture_pageleave: true,
      session_recording: {
        maskAllInputs: true, // mask passwords, credit cards
      },
    })
  }, [])
 
  return <PHProvider client={posthog}>{children}</PHProvider>
}

Add to app/layout.tsx:

// app/layout.tsx
import { PostHogProvider } from '@/providers/PostHogProvider'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <PostHogProvider>
          {children}
        </PostHogProvider>
      </body>
    </html>
  )
}

Proxy to avoid ad blockers

Many users have ad blockers that block analytics requests. Proxy through Next.js:

// next.config.ts
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/ingest/static/:path*',
        destination: 'https://us-assets.i.posthog.com/static/:path*',
      },
      {
        source: '/ingest/:path*',
        destination: 'https://us.i.posthog.com/:path*',
      },
    ]
  },
  skipTrailingSlashRedirect: true,
}
 
export default nextConfig

Identifying users

// hooks/usePostHogIdentify.ts
'use client'
import { useUser } from '@clerk/nextjs'
import { usePostHog } from 'posthog-js/react'
import { useEffect } from 'react'
 
export function usePostHogIdentify() {
  const { user, isSignedIn } = useUser()
  const posthog = usePostHog()
 
  useEffect(() => {
    if (isSignedIn && user) {
      posthog.identify(user.id, {
        email: user.primaryEmailAddress?.emailAddress,
        name: user.fullName,
        plan: user.publicMetadata.plan,
        createdAt: user.createdAt,
      })
    } else if (!isSignedIn) {
      posthog.reset()
    }
  }, [isSignedIn, user, posthog])
}

Tracking key events

Don't track everything — track events that answer real business questions.

// lib/analytics.ts
import { usePostHog } from 'posthog-js/react'
 
// Events that matter for a SaaS
export const EVENTS = {
  // Acquisition
  SIGNED_UP: 'signed_up',
  ONBOARDING_COMPLETED: 'onboarding_completed',
 
  // Activation
  FIRST_PROJECT_CREATED: 'first_project_created',
  FEATURE_USED: 'feature_used',
 
  // Revenue
  UPGRADE_CLICKED: 'upgrade_clicked',
  CHECKOUT_STARTED: 'checkout_started',
  SUBSCRIPTION_CREATED: 'subscription_created',
 
  // Retention
  PROJECT_SHARED: 'project_shared',
  EXPORT_USED: 'export_used',
} as const
 
// Usage in components
export function useTrack() {
  const posthog = usePostHog()
 
  return (event: string, properties?: Record<string, unknown>) => {
    posthog.capture(event, properties)
  }
}
// In a component
function UpgradeButton({ plan }: { plan: string }) {
  const track = useTrack()
 
  return (
    <button
      onClick={() => {
        track(EVENTS.UPGRADE_CLICKED, { plan, location: 'dashboard-banner' })
        router.push('/pricing')
      }}
    >
      Upgrade to Pro
    </button>
  )
}

Server-side event capture

For events that happen server-side (Stripe webhooks, cron jobs), use posthog-node:

// lib/analytics-server.ts
import { PostHog } from 'posthog-node'
 
const posthogClient = new PostHog(process.env.POSTHOG_KEY!, {
  host: 'https://us.i.posthog.com',
  flushAt: 1,     // flush immediately in serverless
  flushInterval: 0,
})
 
export async function trackServer(
  userId: string,
  event: string,
  properties?: Record<string, unknown>
) {
  posthogClient.capture({
    distinctId: userId,
    event,
    properties,
  })
  await posthogClient.shutdown()
}
// app/api/webhooks/stripe/route.ts
// After subscription is created:
await trackServer(userId, 'subscription_created', {
  plan: planName,
  interval: 'monthly',
  amount: session.amount_total,
})

Structured logging with Pino

console.log is fine locally. In production you need logs you can search, filter, and alert on.

Pino writes JSON logs. JSON logs are what log aggregators (Axiom, Datadog, Logtail) can index and query.

npm install pino pino-pretty
// lib/logger.ts
import pino from 'pino'
 
const isDev = process.env.NODE_ENV === 'development'
 
export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  // Pretty-print in dev, JSON in production
  ...(isDev
    ? {
        transport: {
          target: 'pino-pretty',
          options: { colorize: true, translateTime: 'HH:MM:ss' },
        },
      }
    : {}),
  // Add base fields to every log
  base: {
    env: process.env.NODE_ENV,
    service: 'stacknotice-api',
  },
  // Redact sensitive fields
  redact: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
})

Use it with child loggers to add request-level context:

// middleware.ts or a request handler wrapper
export function createRequestLogger(requestId: string, userId?: string) {
  return logger.child({
    requestId,
    userId,
  })
}
// app/api/generate/route.ts
import { logger } from '@/lib/logger'
 
export async function POST(req: Request) {
  const requestId = crypto.randomUUID()
  const log = logger.child({ requestId, route: '/api/generate' })
 
  log.info('Generation request started')
 
  try {
    const result = await callAI(prompt)
    log.info({ tokensUsed: result.usage.total_tokens }, 'Generation completed')
    return Response.json(result)
  } catch (err) {
    log.error({ err, promptLength: prompt.length }, 'Generation failed')
    throw err
  }
}

The output in production is searchable JSON:

{"level":30,"time":1716700000000,"requestId":"abc-123","route":"/api/generate","msg":"Generation completed","tokensUsed":1247}
{"level":50,"time":1716700001000,"requestId":"abc-123","route":"/api/generate","msg":"Generation failed","err":{"type":"RateLimitError"},"promptLength":543}

Log aggregation with Axiom

Vercel's log drain sends your logs to a third-party aggregator. Axiom has a free tier (500 GB/month, 30-day retention) and a Vercel integration.

Setup:

  1. Create account at axiom.co
  2. In Vercel: Settings → Log Drains → Add drain → select Axiom
  3. Set LOG_LEVEL=info in Vercel environment variables

Once connected, you can query logs in Axiom:

// Find all errors in the last hour
| where level == 50
| where _time > ago(1h)

// Find slow requests
| where route contains "/api"
| where duration > 2000

// Find errors for a specific user
| where userId == "user_abc123"
| where level >= 40
Structured logging enables instant debugging

When a user reports a bug, search Axiom for their userId in the last 24 hours. You'll see exactly what they did, what your app did, and where it failed — in seconds instead of hours.


Key metrics to track

Not everything in your DB. Focus on metrics that indicate system health and business health:

Technical metrics (Sentry Performance)

  • p95 API response time — alert if >2s for any route
  • Error rate by route — alert if >1% errors on any route
  • Database query time — alert if any query exceeds 500ms
  • Webhook delivery rate — alert if Stripe webhook failures exceed 5%

Business metrics (PostHog)

  • Activation rate: % of signups that complete onboarding
  • Feature adoption: which features do paying users use vs free users
  • Upgrade conversion: % of free users who visit pricing and convert
  • Churn leading indicators: users who haven't logged in for 14 days

Setting up alerts

Sentry: Dashboard → Alerts → Create Alert → set threshold and notification channel (Slack/email)

PostHog: Insights → create a funnel or trend → set alert → choose threshold


What to monitor from day 1 vs later

Not everything needs to be set up before launch. Here's what actually matters early:

Before launch (non-negotiable)

  • Sentry error tracking with user context
  • Basic structured logging (logger.error at minimum)
  • Stripe webhook failure alerting (Stripe Dashboard → Webhooks → alert on failure rate)
  • Uptime monitoring (free with Vercel, or Better Uptime)

After first paying customer

  • PostHog installed and identifying users
  • Activation funnel tracking (signup → onboarding → first feature use)
  • Payment failure alerting (already covered in the Stripe article)

After 100 users

  • Log aggregation (Axiom or Logtail)
  • Database query performance monitoring
  • P95 latency alerts for critical paths

After 1,000 users

  • Distributed tracing with OpenTelemetry
  • Custom dashboards for business KPIs
  • SLA monitoring
The cost at launch

Sentry: free (5k errors/month). PostHog: free (1M events). Axiom: free (500 GB/month). Vercel log drain: free. Total cost at launch: $0.


Putting it all together: the observable request

Here's what a fully observable request looks like — error tracking, analytics, and structured logging working together:

// app/api/projects/route.ts
import { auth } from '@clerk/nextjs/server'
import * as Sentry from '@sentry/nextjs'
import { logger } from '@/lib/logger'
import { trackServer } from '@/lib/analytics-server'
import { db } from '@/lib/db'
 
export async function POST(req: Request) {
  const requestId = crypto.randomUUID()
  const log = logger.child({ requestId, route: 'POST /api/projects' })
 
  const { userId } = await auth()
  if (!userId) {
    log.warn('Unauthenticated request')
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const childLog = log.child({ userId })
  childLog.info('Creating project')
 
  try {
    const body = await req.json()
    const project = await db.insert(projects).values({
      ...body,
      ownerId: userId,
    }).returning()
 
    childLog.info({ projectId: project[0].id }, 'Project created')
 
    // Track for product analytics
    await trackServer(userId, 'project_created', {
      projectId: project[0].id,
      template: body.template,
    })
 
    return Response.json(project[0], { status: 201 })
  } catch (err) {
    childLog.error({ err }, 'Project creation failed')
 
    // Send to Sentry with context
    Sentry.captureException(err, {
      tags: { route: 'POST /api/projects', userId },
      extra: { requestId },
    })
 
    return Response.json({ error: 'Failed to create project' }, { status: 500 })
  }
}

Every layer adds context. When something breaks in production, you can trace it from Sentry (what failed) → Axiom logs (full request timeline) → PostHog (what the user was doing before it failed).


Production checklist

1
Verify Sentry is capturing errors

Add a test route app/api/test-error/route.ts that throws. Hit it, verify the error appears in Sentry within 30 seconds.

2
Verify PostHog identifies users correctly

Sign in, check PostHog People → find your email. Verify properties (plan, email) are correct.

3
Verify structured logs reach Axiom

Make a request. Check Axiom → verify JSON logs with requestId and userId appear.

4
Set up Sentry alerts

Create alerts for: new error types, error rate spike (>10x baseline), p95 latency >2s.

5
Set up the activation funnel in PostHog

Funnel: signed_up → onboarding_completed → first_project_created. This is your most important metric.


What's next in the series

  • SaaS Series #6: CI/CD real — GitHub Actions, preview deploys, staging environments
  • SaaS Series #7: Multi-tenancy — organizations, Row-Level Security

Also relevant: feature flags in Next.js covers PostHog's feature flag system, which integrates directly with the analytics setup in this article.

#nextjs#sentry#posthog#monitoring#saas#observability
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.