Back to recipes

Observability & Monitoring

6 recipes

Complete observability stack with structured logging, error tracking, and web analytics.

Cookbooks/Plugins

Pino Logging Setup

Configure structured logging with Pino. Outputs human-readable colorized logs in development and structured JSON in production for log aggregation services.

Step 1: Install Pino

bash
bun add pino pino-pretty
  • pino - Fast JSON logger for Node.js
  • pino-pretty - Pretty-prints logs in development

Step 2: Configure Next.js

Add pino to serverExternalPackages in next.config.ts to prevent Turbopack from bundling pino's dependencies (which include test files that break the build):

typescript
const nextConfig: NextConfig = {
  // Externalize pino to prevent Turbopack from bundling thread-stream test files
  serverExternalPackages: ["pino"],
};

Step 3: Add the logger utility

Install via shadcn registry

optional
bunx --bun shadcn@latest add https://fullstackrecipes.com/r/logger.json

Key features:

  • Uses pino-pretty in development for human-readable colorized output
  • Outputs JSON in production (for log aggregation services)
  • Log level configurable via PINO_LOG_LEVEL env var (defaults to info)

Usage

typescript
import { logger } from "@/lib/common/logger";

// Basic logging
logger.info("Server started", { port: 3000 });
logger.warn("Rate limit reached", { endpoint: "/api/chat" });

// Log errors with stack traces
logger.error(err, "Failed to process request");

// Add context
logger.info({ userId: "123", action: "login" }, "User logged in");

Log levels (ordered by severity): trace, debug, info, warn, error, fatal


File Structure

src/lib/common/
  logger.ts    # Pino logger utility

References


Sentry Setup

Configure Sentry for error tracking, performance monitoring, and log aggregation. Integrates with Pino to forward logs to Sentry automatically.

Step 1: Run the Sentry Wizard

Create a new Sentry project at sentry.io, then configure your app automatically by running the Sentry wizard in your project root. You can find the personalized command in the Sentry getting-started guide during project creation.

bash
bunx @sentry/wizard@latest -i nextjs --saas --org <org-name> --project <project-name>

Wizard selections:

  • Runtime: Bun
  • Route requests through your Next.js server: Yes (optional, recommended for privacy)
  • Enable Tracing: Yes
  • Session Replay: Yes
  • Logs: Yes
  • Example page: No
  • Add Sentry MCP server: Yes

The wizard creates and updates the following TypeScript files:

  • next.config.ts - Next.js configuration
  • sentry.server.config.ts - Server-side initialization (we'll delete this)
  • sentry.edge.config.ts - Edge runtime initialization (we'll delete this)
  • src/instrumentation-client.ts - Client-side initialization
  • src/instrumentation.ts - Instrumentation hook
  • src/app/global-error.tsx - Global error component

Step 2: Add environment variables

Add to your .env.development:

env
NEXT_PUBLIC_ENABLE_SENTRY="true"
NEXT_PUBLIC_SENTRY_DSN="https://your-dsn@sentry.io/your-project-id"
NEXT_PUBLIC_SENTRY_PROJECT="your-project-name"
NEXT_PUBLIC_SENTRY_ORG="your-org-name"
SENTRY_AUTH_TOKEN="your-auth-token"

Then sync to Vercel with bun run env:push.

You can find your Sentry DSN, project name, and org name in your Sentry project settings or within the scaffolded files generated by the Sentry wizard. They're not secrets - they just tell Sentry where to send data. We move these to .env.development to enforce validation and also avoid committing them when working on open source repositories.

SENTRY_AUTH_TOKEN is a secret token added in .env.sentry-build-plugin by the Sentry wizard. You can delete the .env.sentry-build-plugin file after adding the token to .env.development. After that, you can also revert the changes made to .gitignore by removing the .env.sentry-build-plugin line.


Step 3: Create the Sentry config

Create the Sentry config with environment variable validation:

ts
import { loadConfig } from "../common/load-config";

export const sentryConfig = loadConfig({
  name: "Sentry",
  flag: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
  server: {
    // SENTRY_AUTH_TOKEN is picked up by the Sentry Build Plugin for source maps upload.
    // Accessing this on the client will throw ServerConfigClientAccessError.
    token: process.env.SENTRY_AUTH_TOKEN,
  },
  public: {
    dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
    project: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
    org: process.env.NEXT_PUBLIC_SENTRY_ORG,
  },
});

We use the loadConfig utility to validate the configuration and throw an error if any of the required environment variables are missing. The config separates server and public sections:

  • server.* values are only accessible on the server - accessing them on the client throws a helpful error
  • public.* values work everywhere (Next.js inlines NEXT_PUBLIC_* vars at build time)

Step 4: Create the initialization helpers

Next, we'll refactor the wizard-generated files to use the new Sentry config. We further move the Sentry-related code into the src/lib/sentry directory to keep them organized.

Create the server initialization helper:

ts
import * as Sentry from "@sentry/nextjs";
import { sentryConfig } from "./config";

export function initSentryServer() {
  if (!sentryConfig.isEnabled) return;

  Sentry.init({
    dsn: sentryConfig.public.dsn,
    tracesSampleRate: 1,
    enableLogs: true,
    sendDefaultPii: true,
    integrations: [
      Sentry.pinoIntegration({ log: { levels: ["info", "warn", "error"] } }),
    ],
  });
}

Create the edge initialization helper:

ts
import * as Sentry from "@sentry/nextjs";
import { sentryConfig } from "./config";

export function initSentryEdge() {
  if (!sentryConfig.isEnabled) return;

  Sentry.init({
    dsn: sentryConfig.public.dsn,
    tracesSampleRate: 1,
    enableLogs: true,
    sendDefaultPii: true,
  });
}

Note: The pino integration is not included for edge because pino uses Node.js modules (fs, events, worker_threads) that aren't available in the edge runtime.

Create the client initialization helper:

ts
import * as Sentry from "@sentry/nextjs";
import { sentryConfig } from "./config";

export function initSentryClient() {
  if (!sentryConfig.isEnabled) return;

  Sentry.init({
    dsn: sentryConfig.public.dsn,
    integrations: [Sentry.replayIntegration()],
    tracesSampleRate: 1,
    enableLogs: true,
    replaysSessionSampleRate: 0.1,
    replaysOnErrorSampleRate: 1.0,
    sendDefaultPii: true,
  });
}

export const onRouterTransitionStart = sentryConfig.isEnabled
  ? Sentry.captureRouterTransitionStart
  : () => {};

Step 5: Update the wizard-generated files

Delete the wizard-generated sentry.server.config.ts and sentry.edge.config.ts files from the project root. We'll import directly from our src/lib/sentry/ modules instead.

Replace the instrumentation file:

ts
import * as Sentry from "@sentry/nextjs";
import { sentryConfig } from "./lib/sentry/config";

export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { initSentryServer } = await import("./lib/sentry/server");
    initSentryServer();
  }

  if (process.env.NEXT_RUNTIME === "edge") {
    const { initSentryEdge } = await import("./lib/sentry/edge");
    initSentryEdge();
  }
}

export const onRequestError = sentryConfig.isEnabled
  ? Sentry.captureRequestError
  : undefined;

Replace the client instrumentation file:

ts
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/

import { initSentryClient, onRouterTransitionStart } from "./lib/sentry/client";

initSentryClient();

export { onRouterTransitionStart };

Step 6: Update next.config.ts

The Sentry wizard updates next.config.ts with hardcoded org and project values. Replace them with environment variables:

typescript
// next.config.ts
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";

const nextConfig: NextConfig = {
  /* config options here */
};

export default withSentryConfig(nextConfig, {
  // For all available options, see:
  // https://www.npmjs.com/package/@sentry/webpack-plugin#options

  org: process.env.NEXT_PUBLIC_SENTRY_ORG,
  project: process.env.NEXT_PUBLIC_SENTRY_PROJECT,

  // Only print logs for uploading source maps in CI
  silent: !process.env.CI,

  // For all available options, see:
  // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/

  // Upload a larger set of source maps for prettier stack traces (increases build time)
  widenClientFileUpload: true,

  // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
  // This can increase your server load as well as your hosting bill.
  // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
  // side errors will fail.
  tunnelRoute: "/monitoring",

  webpack: {
    // Automatically tree-shake Sentry logger statements to reduce bundle size
    treeshake: {
      removeDebugLogging: true,
    },
    // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
    // See the following for more information:
    // https://docs.sentry.io/product/crons/
    // https://vercel.com/docs/cron-jobs
    automaticVercelMonitors: true,
  },
});

Database query monitoring

When tracing is enabled (tracesSampleRate > 0), Sentry automatically instruments database queries using postgresIntegration. This is included by default - no additional configuration needed.

What you get automatically:

  • All pg (node-postgres) queries captured as spans
  • Query timing and slow query detection
  • Database performance visible in Sentry's Performance tab
  • Works with Drizzle ORM since it uses node-postgres under the hood

This uses OpenTelemetry instrumentation (@opentelemetry/instrumentation-pg) to hook into the pg library. Supports pg versions 8.x.

To disable or customize (if needed):

typescript
Sentry.init({
  dsn: "...",
  tracesSampleRate: 1,
  integrations: (defaults) => {
    // Remove postgres integration
    return defaults.filter((i) => i.name !== "Postgres");
  },
});

Step 7: Create Cursor rules for Sentry

Create .cursor/rules/sentry.mdc to help AI coding agents use Sentry APIs correctly:

markdown
---
description: Sentry error monitoring and tracking
alwaysApply: false
---

These examples should be used as guidance when configuring Sentry functionality within a project.

# Exception Catching

Use `Sentry.captureException(error)` to capture an exception and log the error in Sentry.

Use this in try catch blocks or areas where exceptions are expected

# Tracing Examples

Spans should be created for meaningful actions within an applications like button clicks, API calls, and function calls

Use the `Sentry.startSpan` function to create a span

Child spans can exist within a parent span

## Custom Span instrumentation in component actions

The `name` and `op` properties should be meaninful for the activities in the call.

Attach attributes based on relevant information and metrics from the request

```tsx
function TestComponent() {
  const handleTestButtonClick = () => {
    // Create a transaction/span to measure performance
    Sentry.startSpan(
      {
        op: "ui.click",
        name: "Test Button Click",
      },
      (span) => {
        const value = "some config";
        const metric = "some metric";
        // Metrics can be added to the span
        span.setAttribute("config", value);
        span.setAttribute("metric", metric);
        doSomething();
      },
    );
  };
  return (
    <button type="button" onClick={handleTestButtonClick}>
      Test Sentry
    </button>
  );
}
```

## Custom span instrumentation in API calls

The `name` and `op` properties should be meaninful for the activities in the call.

Attach attributes based on relevant information and metrics from the request

```typescript
async function fetchUserData(userId) {
  return Sentry.startSpan(
    {
      op: "http.client",
      name: `GET /api/users/${userId}`,
    },
    async () => {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      return data;
    },
  );
}
```

# Logs

Where logs are used, ensure Sentry is imported using `import * as Sentry from "@sentry/nextjs"`

Enable logging in Sentry using `Sentry.init({  enableLogs: true })`

Reference the logger using `const { logger } = Sentry`

Sentry offers a consoleLoggingIntegration that can be used to log specific console error types automatically without instrumenting the individual logger calls

## Configuration

In NextJS the client side Sentry initialization is in `instrumentation-client.(js|ts)`, the server initialization is in `sentry.server.config.ts` and the edge initialization is in `sentry.edge.config.ts`

Initialization does not need to be repeated in other files, it only needs to happen the files mentioned above. You should use `import * as Sentry from "@sentry/nextjs"` to reference Sentry functionality

### Baseline

```typescript
import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: "https://your-dsn@sentry.io/your-project-id",
  enableLogs: true,
});
```

### Logger Integration

```typescript
Sentry.init({
  dsn: "https://your-dsn@sentry.io/your-project-id",
  integrations: [
    // send console.log, console.warn, and console.error calls as logs to Sentry
    Sentry.consoleLoggingIntegration({ levels: ["log", "warn", "error"] }),
  ],
});
```

## Logger Examples

`logger.fmt` is a template literal function that should be used to bring variables into the structured logs.

```typescript
logger.trace("Starting database connection", { database: "users" });
logger.debug(logger.fmt`Cache miss for user: ${userId}`);
logger.info("Updated profile", { profileId: 345 });
logger.warn("Rate limit reached for endpoint", {
  endpoint: "/api/results/",
  isEnterprise: false,
});
logger.error("Failed to process payment", {
  orderId: "order_123",
  amount: 99.99,
});
logger.fatal("Database connection pool exhausted", {
  database: "users",
  activeConnections: 100,
});
```

File Structure

After setup, you'll have these Sentry-related files:

src/
  instrumentation.ts           # Next.js instrumentation hook - calls initSentryServer() or initSentryEdge()
  instrumentation-client.ts    # Client instrumentation - calls initSentryClient()
  lib/sentry/
    config.ts                  # Validates NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_SENTRY_PROJECT, NEXT_PUBLIC_SENTRY_ORG
    server.ts                  # initSentryServer() with pino integration
    edge.ts                    # initSentryEdge() without pino (edge doesn't support Node.js modules)
    client.ts                  # initSentryClient() with replay integration
.cursor/rules/
  sentry.mdc                   # AI agent guidelines for Sentry

References


Vercel Web Analytics

Add privacy-focused web analytics with Vercel Web Analytics. Track page views, visitors, and custom events with zero configuration.

Step 1: Install the package

bash
bun add @vercel/analytics

Step 2: Add the Analytics component

Add the Analytics component to your root layout:

tsx
import { Analytics } from "@vercel/analytics/next";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

That's it! Page views are now tracked automatically.


Custom Events

Track custom events to measure user actions:

typescript
import { track } from "@vercel/analytics";

// Track a button click
function SignupButton() {
  return (
    <button onClick={() => track("signup_clicked")}>
      Sign Up
    </button>
  );
}

// Track with properties
track("purchase_completed", {
  plan: "pro",
  price: 29,
  currency: "USD",
});

// Track form submissions
function ContactForm() {
  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    track("contact_form_submitted", { source: "footer" });
    // ... submit form
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

Development

Analytics are only sent in production by default. To test in development, set the mode prop:

typescript
<Analytics mode="development" />

Or use the debug prop to log events to the console:

typescript
<Analytics debug />

References


Working with Logging

Use structured logging with Pino throughout your application. Covers log levels, context, and workflow-safe logging patterns.

Implement Working with Logging

Use structured logging with Pino throughout your application. Covers log levels, context, and workflow-safe logging patterns.

See:

  • Resource: using-logging in Fullstack Recipes
  • URL: https://fullstackrecipes.com/recipes/using-logging

Basic Logging

Import the logger and use it throughout your application:

typescript
import { logger } from "@/lib/common/logger";

// Info level for normal operations
logger.info("Server started", { port: 3000 });

// Warn level for recoverable issues
logger.warn("Rate limit reached", { endpoint: "/api/chat" });

// Error level with Error objects
logger.error(err, "Failed to process request");

// Debug level for development troubleshooting
logger.debug("Cache miss", { key: "user:123" });

Structured Logging

Always include context as the first argument for structured logs:

typescript
// Context object first, message second
logger.info({ userId: "123", action: "login" }, "User logged in");

// For errors, pass the error first
logger.error({ err, userId: "123", endpoint: "/api/chat" }, "Request failed");

Log Levels

Use appropriate levels for different scenarios:

LevelWhen to Use
traceDetailed debugging (rarely used)
debugDevelopment troubleshooting
infoNormal operations, business events
warnRecoverable issues, deprecation warnings
errorFailures that need attention
fatalCritical failures, app cannot continue

Configuring Log Level

Set the PINO_LOG_LEVEL environment variable:

env
# Show all logs including debug
PINO_LOG_LEVEL="debug"

# Production: only warnings and errors
PINO_LOG_LEVEL="warn"

Default is info if not set.

Logging in API Routes

typescript
import { logger } from "@/lib/common/logger";

export async function POST(request: Request) {
  const start = Date.now();

  try {
    const result = await processRequest(request);

    logger.info(
      { duration: Date.now() - start, status: 200 },
      "Request completed",
    );

    return Response.json(result);
  } catch (err) {
    logger.error({ err, duration: Date.now() - start }, "Request failed");

    return Response.json({ error: "Internal error" }, { status: 500 });
  }
}

Logging in Workflows

Workflow functions run in a restricted environment. Use the logger step wrapper:

ts
import { logger } from "@/lib/common/logger";

export async function log(
  level: "info" | "warn" | "error" | "debug",
  message: string,
  data?: Record<string, unknown>,
): Promise<void> {
  "use step";

  if (data) {
    logger[level](data, message);
  } else {
    logger[level](message);
  }
}

Then use it in workflows:

typescript
import { log } from "./steps/logger";

export async function chatWorkflow({ chatId }) {
  "use workflow";

  await log("info", "Workflow started", { chatId });
}

References


Working with Sentry

Capture exceptions, add context, create performance spans, and use structured logging with Sentry.

Implement Working with Sentry

Capture exceptions, add context, create performance spans, and use structured logging with Sentry.

See:

  • Resource: using-sentry in Fullstack Recipes
  • URL: https://fullstackrecipes.com/recipes/using-sentry

Capturing Exceptions

Manually capture errors that are handled but should be tracked:

typescript
import * as Sentry from "@sentry/nextjs";

try {
  await riskyOperation();
} catch (err) {
  Sentry.captureException(err);
  // Handle the error gracefully...
}

Adding Context

Attach user and custom context to errors:

typescript
import * as Sentry from "@sentry/nextjs";

// Set user context (persists for session)
Sentry.setUser({
  id: session.user.id,
  email: session.user.email,
});

// Add custom context to exceptions
Sentry.captureException(err, {
  tags: {
    feature: "checkout",
    plan: "pro",
  },
  extra: {
    orderId: "order_123",
    items: cart.items,
  },
});

Performance Tracing

Create spans for meaningful operations:

typescript
import * as Sentry from "@sentry/nextjs";

// Wrap async operations
const result = await Sentry.startSpan(
  {
    op: "http.client",
    name: "GET /api/users",
  },
  async () => {
    const response = await fetch("/api/users");
    return response.json();
  },
);

// Wrap sync operations
Sentry.startSpan(
  {
    op: "ui.click",
    name: "Submit Button Click",
  },
  (span) => {
    span.setAttribute("form", "checkout");
    processSubmit();
  },
);

Using the Sentry Logger

Sentry provides structured logging that appears in the Logs tab:

typescript
import * as Sentry from "@sentry/nextjs";

const { logger } = Sentry;

logger.info("Payment processed", { orderId: "123", amount: 99.99 });
logger.warn("Rate limit approaching", { current: 90, max: 100 });
logger.error("Payment failed", { orderId: "123", reason: "declined" });

Breadcrumbs

Add breadcrumbs to provide context for errors:

typescript
import * as Sentry from "@sentry/nextjs";

// Automatically captured: console logs, fetch requests, UI clicks
// Manual breadcrumbs for custom events:
Sentry.addBreadcrumb({
  category: "auth",
  message: "User signed in",
  level: "info",
});

Clearing User Context

Clear user data on sign out:

typescript
import * as Sentry from "@sentry/nextjs";

async function signOut() {
  Sentry.setUser(null);
  await authClient.signOut();
}

References


Working with Analytics

Track custom events and conversions with Vercel Web Analytics. Covers common events, form tracking, and development testing.

Implement Working with Analytics

Track custom events and conversions with Vercel Web Analytics. Covers common events, form tracking, and development testing.

See:

  • Resource: using-analytics in Fullstack Recipes
  • URL: https://fullstackrecipes.com/recipes/using-analytics

Tracking Custom Events

Track user actions and conversions:

typescript
import { track } from "@vercel/analytics";

// Basic event
track("signup_clicked");

// Event with properties
track("purchase_completed", {
  plan: "pro",
  price: 29,
  currency: "USD",
});

Common Events to Track

Track meaningful user actions:

typescript
// Authentication
track("signup_completed", { method: "email" });
track("signin_completed", { method: "google" });

// Feature usage
track("chat_started");
track("chat_completed", { messageCount: 5 });
track("file_uploaded", { type: "pdf", size: 1024 });

// Conversions
track("trial_started");
track("subscription_created", { plan: "pro" });
track("upgrade_completed", { from: "free", to: "pro" });

Tracking in Components

tsx
import { track } from "@vercel/analytics";

function UpgradeButton() {
  const handleClick = () => {
    track("upgrade_button_clicked", { location: "header" });
    // Navigate to upgrade page...
  };

  return <button onClick={handleClick}>Upgrade</button>;
}

Tracking Form Submissions

tsx
import { track } from "@vercel/analytics";

function ContactForm() {
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();

    track("contact_form_submitted", { source: "footer" });

    // Submit form...
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

Testing in Development

Analytics only send in production by default. For development testing:

tsx
// In layout.tsx
<Analytics mode="development" />

// Or just log to console
<Analytics debug />

Viewing Analytics

View analytics in the Vercel dashboard:

  1. Go to your project in Vercel Dashboard
  2. Click "Analytics" in the sidebar
  3. View page views, visitors, and custom events

References