第 20 章:错误处理与监控(Sentry 与可观测性)

构建 Next.js 的完整错误处理与可观测性体系——从 Error Boundary、全局错误页、Server Action 错误处理,到 Sentry 集成、结构化日志与生产环境监控。

本章目标:建立一套完整的错误处理与监控体系——理解 Next.js App Router 中错误边界的层次结构,掌握 Server Component / Client Component / Server Action / Route Handler 各自的错误处理策略,并集成 Sentry 实现生产环境的实时监控与告警。


20.1 错误类型全景

Next.js 中的错误来源

错误类型               │ 出现位置                 │ 处理策略
──────────────────────┼─────────────────────────┼─────────────────
渲染错误              │ Server / Client Component │ error.tsx
路由错误              │ 全局(未捕获)             │ global-error.tsx
404 错误              │ 找不到页面                │ notFound.tsx
Server Action 错误    │ 表单提交 / 函数调用       │ try/catch + state
Route Handler 错误    │ API 端点                  │ try/catch + NextResponse
Middleware 错误       │ 请求拦截层                │ try/catch + redirect
第三方 API 错误       │ fetch 调用                │ try/catch + fallback
数据库错误            │ Prisma 调用               │ try/catch + 友好提示

错误边界层次结构

Root Layout
  └── global-error.tsx         ← 捕获根布局错误
       └── app/[locale]/layout.tsx
            └── app/[locale]/articles/error.tsx  ← 捕获文章路由段错误
                 └── app/[locale]/articles/[slug]/error.tsx  ← 捕获单篇文章错误
                      └── 具体页面

20.2 Error Boundary(error.tsx)

基础错误边界

// app/articles/error.tsx
'use client';

import { useEffect } from 'react';
import { Button } from '@/components/ui/button';

type ErrorProps = {
  error: Error & { digest?: string };
  reset: () => void;
};

export default function ArticlesError({ error, reset }: ErrorProps) {
  useEffect(() => {
    // 上报到错误监控服务
    console.error('Articles page error:', error);
    // Sentry.captureException(error);
  }, [error]);

  return (
    <div className="max-w-2xl mx-auto py-16 px-4 text-center">
      <div className="text-6xl mb-6">😵</div>
      <h2 className="text-2xl font-bold mb-4">哎呀,出了点问题</h2>
      <p className="text-muted-foreground mb-8">
        加载文章列表时遇到了问题,请稍后再试。
      </p>

      {error.digest && (
        <p className="text-xs text-muted-foreground mb-4">
          错误代码:{error.digest}
        </p>
      )}

      <div className="flex items-center justify-center gap-4">
        <Button onClick={reset}>重试</Button>
        <Button variant="outline" onClick={() => window.location.href = '/'}>
          返回首页
        </Button>
      </div>
    </div>
  );
}

带分类处理的错误边界

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';

// 自定义错误类
class AuthError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'AuthError';
  }
}

class NetworkError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NetworkError';
  }
}

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const router = useRouter();

  useEffect(() => {
    console.error('Dashboard error:', {
      name: error.name,
      message: error.message,
      digest: error.digest,
      stack: error.stack,
    });
  }, [error]);

  // 根据错误类型显示不同 UI
  if (error.name === 'AuthError' || error.message.includes('Unauthorized')) {
    return (
      <div className="max-w-md mx-auto py-16 text-center">
        <div className="text-6xl mb-6">🔐</div>
        <h2 className="text-xl font-bold mb-4">需要登录</h2>
        <p className="text-muted-foreground mb-6">
          请先登录后再访问仪表盘。
        </p>
        <Button onClick={() => router.push('/login?callbackUrl=/dashboard')}>
          去登录
        </Button>
      </div>
    );
  }

  if (error.name === 'NetworkError' || error.message.includes('fetch')) {
    return (
      <div className="max-w-md mx-auto py-16 text-center">
        <div className="text-6xl mb-6">📡</div>
        <h2 className="text-xl font-bold mb-4">网络连接失败</h2>
        <p className="text-muted-foreground mb-6">
          请检查你的网络连接,然后重试。
        </p>
        <Button onClick={reset}>重试</Button>
      </div>
    );
  }

  // 通用错误 UI
  return (
    <div className="max-w-md mx-auto py-16 text-center">
      <div className="text-6xl mb-6">⚠️</div>
      <h2 className="text-xl font-bold mb-4">出了点问题</h2>
      <p className="text-muted-foreground mb-6">
        {error.message || '应用遇到了意外错误。'}
      </p>
      <div className="flex items-center justify-center gap-4">
        <Button onClick={reset}>重试</Button>
        <Button variant="outline" onClick={() => router.push('/')}>
          返回首页
        </Button>
      </div>
    </div>
  );
}

错误边界的限制

❌ error.tsx 不能捕获的错误:
  - 根布局(root layout)中的错误 → 使用 global-error.tsx
  - 静态渲染中的错误(build time)
  - 自身组件内的错误(需要外层 error.tsx)

✅ error.tsx 能捕获的错误:
  - 路由段内 Server / Client Component 的渲染错误
  - 数据获取错误(fetch、数据库查询)
  - useEffect 抛出的错误
  - 事件处理器抛出的错误(如果未 catch)

20.3 全局错误页(global-error.tsx)

根级错误边界

// app/global-error.tsx
'use client';

import { useEffect } from 'react';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 上报到 Sentry
    // Sentry.captureException(error);
    console.error('Global error:', error);
  }, [error]);

  // global-error.tsx 必须包含完整的 <html> 和 <body> 标签
  return (
    <html lang="zh-CN">
      <body>
        <div
          style={{
            minHeight: '100vh',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            backgroundColor: '#f8fafc',
            fontFamily: 'system-ui, -apple-system, sans-serif',
          }}
        >
          <div style={{ textAlign: 'center', padding: '2rem' }}>
            <h1 style={{ fontSize: '6rem', margin: 0 }}>💥</h1>
            <h2
              style={{
                fontSize: '1.5rem',
                fontWeight: 'bold',
                color: '#0f172a',
                marginTop: '1rem',
              }}
            >
              应用出现严重错误
            </h2>
            <p style={{ color: '#64748b', marginTop: '1rem', maxWidth: '400px' }}>
              抱歉,应用遇到了无法恢复的错误。我们的团队已被通知,正在处理中。
            </p>

            {error.digest && (
              <p style={{ fontSize: '0.75rem', color: '#94a3b8', marginTop: '1rem' }}>
                错误 ID{error.digest}
              </p>
            )}

            <div
              style={{
                marginTop: '2rem',
                display: 'flex',
                gap: '1rem',
                justifyContent: 'center',
              }}
            >
              <button
                onClick={reset}
                style={{
                  padding: '0.75rem 1.5rem',
                  backgroundColor: '#3b82f6',
                  color: 'white',
                  border: 'none',
                  borderRadius: '0.5rem',
                  cursor: 'pointer',
                  fontSize: '1rem',
                }}
              >
                重试
              </button>
              <a
                href="/"
                style={{
                  padding: '0.75rem 1.5rem',
                  backgroundColor: 'white',
                  color: '#0f172a',
                  border: '1px solid #e2e8f0',
                  borderRadius: '0.5rem',
                  textDecoration: 'none',
                  fontSize: '1rem',
                }}
              >
                返回首页
              </a>
            </div>
          </div>
        </div>
      </body>
    </html>
  );
}

20.4 404 页面(notFound.tsx)

自定义 404 页面

// app/not-found.tsx

import Link from 'next/link';
import { Button } from '@/components/ui/button';

export default function NotFound() {
  return (
    <div className="min-h-[70vh] flex items-center justify-center px-4">
      <div className="text-center max-w-lg">
        <div className="relative mb-8">
          <h1 className="text-9xl font-bold text-muted-foreground/10 select-none">
            404
          </h1>
          <div className="absolute inset-0 flex items-center justify-center">
            <span className="text-6xl">🔍</span>
          </div>
        </div>

        <h2 className="text-2xl font-bold mb-4">页面走丢了</h2>
        <p className="text-muted-foreground mb-8">
          你访问的页面不存在,或者已经被移动到其他位置。
        </p>

        <div className="flex items-center justify-center gap-4">
          <Link href="/">
            <Button>返回首页</Button>
          </Link>
          <Link href="/articles">
            <Button variant="outline">浏览文章</Button>
          </Link>
        </div>

        <div className="mt-12 text-sm text-muted-foreground">
          <p>如果你认为这是一个错误,请</p>
          <Link href="/contact" className="text-primary hover:underline">
            联系我们
          </Link>
        </div>
      </div>
    </div>
  );
}

在 Server Component 中触发 404

// app/articles/[slug]/page.tsx

import { notFound } from 'next/navigation';
import { getArticle } from '@/lib/services/article';

export default async function ArticlePage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const article = await getArticle(slug);

  if (!article) {
    // 触发 not-found.tsx
    notFound();
  }

  return <article>{/* ... */}</article>;
}

20.5 Server Action 错误处理

标准错误处理模式

// app/actions/article.ts
'use server';

import { prisma } from '@/lib/prisma';
import { requireAuth } from '@/lib/auth-utils';

// 错误类型定义
export type ActionError = {
  error: string;
  fieldErrors?: Record<string, string>;
  code?: 'UNAUTHORIZED' | 'NOT_FOUND' | 'CONFLICT' | 'VALIDATION' | 'INTERNAL';
};

export type ActionResult<T> =
  | { success: true; data: T }
  | { success: false } & ActionError;

// Server Action 实现
export async function updateArticle(
  id: string,
  data: UpdateArticleInput
): Promise<ActionResult<Article>> {
  try {
    // 1. 认证检查
    const user = await requireAuth();

    // 2. 输入验证
    const validation = updateArticleSchema.safeParse(data);
    if (!validation.success) {
      return {
        success: false,
        error: '输入验证失败',
        fieldErrors: Object.fromEntries(
          validation.error.issues.map((i) => [i.path[0], i.message])
        ),
        code: 'VALIDATION',
      };
    }

    // 3. 权限检查
    const article = await prisma.article.findUnique({
      where: { id },
      select: { authorId: true },
    });

    if (!article) {
      return {
        success: false,
        error: '文章不存在',
        code: 'NOT_FOUND',
      };
    }

    if (article.authorId !== user.id && user.role !== 'admin') {
      return {
        success: false,
        error: '没有权限修改此文章',
        code: 'UNAUTHORIZED',
      };
    }

    // 4. 业务逻辑
    const updated = await prisma.article.update({
      where: { id },
      data: validation.data,
    });

    revalidatePath(`/articles/${updated.slug}`);

    return { success: true, data: updated };
  } catch (error) {
    // 5. 未知错误兜底
    console.error('updateArticle failed:', error);

    // 上报到 Sentry
    // Sentry.captureException(error);

    return {
      success: false,
      error: '操作失败,请稍后重试',
      code: 'INTERNAL',
    };
  }
}

在组件中处理错误

// app/components/article-form.tsx
'use client';

import { useActionState } from 'react';
import { updateArticle, ActionResult } from '@/app/actions/article';
import { toast } from 'sonner';

export function ArticleForm({ article }: { article: Article }) {
  const [state, formAction] = useActionState<ActionResult<Article>, FormData>(
    async (prevState, formData) => {
      const data = {
        title: formData.get('title') as string,
        content: formData.get('content') as string,
      };

      const result = await updateArticle(article.id, data);

      if (result.success) {
        toast.success('文章已更新');
      } else {
        // 根据错误代码显示不同提示
        switch (result.code) {
          case 'VALIDATION':
            // 字段级错误由 FormMessage 显示
            break;
          case 'UNAUTHORIZED':
            toast.error('权限不足,请重新登录');
            break;
          case 'NOT_FOUND':
            toast.error('文章不存在');
            break;
          default:
            toast.error(result.error);
        }
      }

      return result;
    },
    { success: true, data: article }
  );

  return (
    <form action={formAction}>
      {/* ... 表单字段 */}
    </form>
  );
}

20.6 Route Handler 错误处理

统一错误响应

// lib/api-errors.ts

import { NextResponse } from 'next/server';

export class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

export class BadRequestError extends ApiError {
  constructor(message = '请求参数错误') {
    super(400, message, 'BAD_REQUEST');
  }
}

export class UnauthorizedError extends ApiError {
  constructor(message = '未登录或登录已过期') {
    super(401, message, 'UNAUTHORIZED');
  }
}

export class ForbiddenError extends ApiError {
  constructor(message = '没有权限执行此操作') {
    super(403, message, 'FORBIDDEN');
  }
}

export class NotFoundError extends ApiError {
  constructor(message = '资源不存在') {
    super(404, message, 'NOT_FOUND');
  }
}

export class ConflictError extends ApiError {
  constructor(message = '资源冲突') {
    super(409, message, 'CONFLICT');
  }
}

export class InternalError extends ApiError {
  constructor(message = '服务器内部错误') {
    super(500, message, 'INTERNAL_ERROR');
  }
}

// 错误处理装饰器
export function withErrorHandler(
  handler: (request: NextRequest, ...args: any[]) => Promise<NextResponse>
) {
  return async (request: NextRequest, ...args: any[]) => {
    try {
      return await handler(request, ...args);
    } catch (error) {
      if (error instanceof ApiError) {
        return NextResponse.json(
          {
            error: error.message,
            code: error.code,
          },
          { status: error.statusCode }
        );
      }

      // 未知错误
      console.error('Unhandled API error:', error);
      // Sentry.captureException(error);

      return NextResponse.json(
        {
          error: '服务器内部错误',
          code: 'INTERNAL_ERROR',
        },
        { status: 500 }
      );
    }
  };
}

在 Route Handler 中使用

// app/api/v1/articles/[id]/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import {
  NotFoundError,
  ForbiddenError,
  BadRequestError,
  withErrorHandler,
} from '@/lib/api-errors';
import { authenticate } from '@/lib/auth-middleware';

type Params = { params: Promise<{ id: string }> };

export const GET = withErrorHandler(async (
  request: NextRequest,
  { params }: Params
) => {
  const { id } = await params;

  const article = await prisma.article.findUnique({
    where: { id },
  });

  if (!article) {
    throw new NotFoundError('文章不存在');
  }

  return NextResponse.json(article);
});

export const PUT = withErrorHandler(async (
  request: NextRequest,
  { params }: Params
) => {
  const { id } = await params;

  // 认证
  const user = await authenticate(request);
  if (!user) {
    throw new UnauthorizedError();
  }

  // 检查存在
  const article = await prisma.article.findUnique({ where: { id } });
  if (!article) {
    throw new NotFoundError('文章不存在');
  }

  // 权限检查
  if (article.authorId !== user.id && user.role !== 'admin') {
    throw new ForbiddenError();
  }

  // 解析 body
  const body = await request.json();

  // 验证
  if (!body.title || body.title.length < 1) {
    throw new BadRequestError('标题不能为空');
  }

  const updated = await prisma.article.update({
    where: { id },
    data: body,
  });

  return NextResponse.json(updated);
});

20.7 结构化日志

日志系统封装

// lib/logger.ts

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

type LogContext = {
  requestId?: string;
  userId?: string;
  path?: string;
  method?: string;
  duration?: number;
  [key: string]: unknown;
};

class Logger {
  private level: LogLevel;
  private levels: Record<LogLevel, number> = {
    debug: 0,
    info: 1,
    warn: 2,
    error: 3,
  };

  constructor() {
    this.level = (process.env.LOG_LEVEL as LogLevel) || 'info';
  }

  private shouldLog(level: LogLevel): boolean {
    return this.levels[level] >= this.levels[this.level];
  }

  private format(level: LogLevel, message: string, context?: LogContext) {
    return {
      timestamp: new Date().toISOString(),
      level,
      message,
      ...context,
    };
  }

  private write(level: LogLevel, message: string, context?: LogContext) {
    if (!this.shouldLog(level)) return;

    const logEntry = this.format(level, message, context);
    const output = JSON.stringify(logEntry);

    switch (level) {
      case 'debug':
        console.debug(output);
        break;
      case 'info':
        console.info(output);
        break;
      case 'warn':
        console.warn(output);
        break;
      case 'error':
        console.error(output);
        break;
    }
  }

  debug(message: string, context?: LogContext) {
    this.write('debug', message, context);
  }

  info(message: string, context?: LogContext) {
    this.write('info', message, context);
  }

  warn(message: string, context?: LogContext) {
    this.write('warn', message, context);
  }

  error(message: string, error?: Error, context?: LogContext) {
    this.write('error', message, {
      ...context,
      errorName: error?.name,
      errorMessage: error?.message,
      errorStack: process.env.NODE_ENV === 'development' ? error?.stack : undefined,
    });
  }
}

export const logger = new Logger();

在 Middleware 中使用

// middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { logger } from '@/lib/logger';

export async function middleware(request: NextRequest) {
  const requestId = crypto.randomUUID();
  const startTime = Date.now();

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('X-Request-Id', requestId);

  logger.info('Request started', {
    requestId,
    method: request.method,
    path: request.nextUrl.pathname,
    userAgent: request.headers.get('user-agent') || 'unknown',
    ip: request.ip || 'unknown',
  });

  try {
    const response = NextResponse.next({
      request: { headers: requestHeaders },
    });

    const duration = Date.now() - startTime;
    response.headers.set('X-Request-Id', requestId);
    response.headers.set('X-Response-Time', `${duration}ms`);

    logger.info('Request completed', {
      requestId,
      method: request.method,
      path: request.nextUrl.pathname,
      duration,
    });

    return response;
  } catch (error) {
    logger.error('Request failed', error as Error, {
      requestId,
      method: request.method,
      path: request.nextUrl.pathname,
    });

    throw error;
  }
}

在 Server Action 中使用

// app/actions/article.ts
'use server';

import { logger } from '@/lib/logger';

export async function createArticle(formData: FormData) {
  const user = await requireAuth();

  logger.info('Article creation started', {
    userId: user.id,
    title: formData.get('title') as string,
  });

  const startTime = Date.now();

  try {
    const article = await prisma.article.create({ /* ... */ });

    logger.info('Article created', {
      userId: user.id,
      articleId: article.id,
      duration: Date.now() - startTime,
    });

    return { success: true, data: article };
  } catch (error) {
    logger.error('Article creation failed', error as Error, {
      userId: user.id,
      duration: Date.now() - startTime,
    });

    return { success: false, error: '创建失败' };
  }
}

20.8 Sentry 集成

安装

npm install @sentry/nextjs

# 或使用向导
npx @sentry/wizard@latest -i nextjs

客户端配置

// sentry.client.config.ts

import * as Sentry from '@sentry/nextjs';

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

  // 性能监控采样率
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,

  // Session Replay(用户操作回放)
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,

  integrations: [
    Sentry.replayIntegration({
      maskAllText: false,
      blockAllMedia: false,
    }),
    Sentry.feedbackIntegration({
      colorScheme: 'system',
    }),
  ],

  // 忽略已知无害错误
  ignoreErrors: [
    'ResizeObserver loop limit exceeded',
    'Non-Error promise rejection captured',
    'NetworkError',
    'Failed to fetch',
  ],

  // 环境标识
  environment: process.env.NODE_ENV,

  // 敏感数据过滤
  beforeSend(event) {
    if (event.request?.headers) {
      delete event.request.headers['Authorization'];
      delete event.request.headers['Cookie'];
    }
    return event;
  },
});

服务端配置

// sentry.server.config.ts

import * as Sentry from '@sentry/nextjs';

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

  // 不捕获 4xx 错误(通常是用户行为导致)
  beforeSend(event, hint) {
    const error = hint.originalException as any;
    if (error?.statusCode && error.statusCode < 500) {
      return null;
    }
    return event;
  },
});

Edge Runtime 配置

// sentry.edge.config.ts

import * as Sentry from '@sentry/nextjs';

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.1,
  environment: process.env.NODE_ENV,
});

全局错误处理

// app/global-error.tsx
'use client';

import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    Sentry.captureException(error);
  }, [error]);

  return (
    <html lang="zh-CN">
      <body>
        {/* ... */}
        <button onClick={() => Sentry.showReportDialog()}>
          反馈问题
        </button>
      </body>
    </html>
  );
}

手动上报错误

// lib/error-reporting.ts

import * as Sentry from '@sentry/nextjs';

export function reportError(
  error: Error,
  context?: Record<string, unknown>
) {
  Sentry.captureException(error, {
    extra: context,
    tags: {
      source: 'manual',
    },
  });
}

export function setUser(user: { id: string; email: string; name?: string }) {
  Sentry.setUser(user);
}

export function clearUser() {
  Sentry.setUser(null);
}

// 性能标记
export function measurePerformance<T>(
  name: string,
  fn: () => Promise<T>
): Promise<T> {
  return Sentry.startSpan({ name, op: 'function' }, async () => {
    return fn();
  });
}

在 Server Action 中使用

// app/actions/payment.ts
'use server';

import * as Sentry from '@sentry/nextjs';

export async function processPayment(amount: number) {
  const user = await requireAuth();

  // 设置用户上下文
  Sentry.setUser({ id: user.id, email: user.email });

  try {
    const result = await chargeUser(user.id, amount);
    return { success: true, data: result };
  } catch (error) {
    // 上报支付错误,附带上下文
    Sentry.captureException(error, {
      extra: {
        userId: user.id,
        amount,
        paymentMethod: 'stripe',
      },
      tags: {
        feature: 'payment',
      },
      level: 'error',
    });

    return { success: false, error: '支付失败' };
  } finally {
    Sentry.setUser(null);
  }
}

next.config.js 配置

// next.config.js

const { withSentryConfig } = require('@sentry/nextjs');

/** @type {import('next').NextConfig} */
const nextConfig = {
  // ... 你的配置
};

module.exports = withSentryConfig(nextConfig, {
  // Sentry Webpack 插件选项
  org: process.env.SENTRY_ORG,
  project: process.env.SENTRY_PROJECT,
  authToken: process.env.SENTRY_AUTH_TOKEN,

  // 静默模式(生产构建)
  silent: process.env.NODE_ENV === 'production',

  // 上传 source maps
  sourcemaps: {
    disable: process.env.NODE_ENV !== 'production',
  },

  // 自动注入 Sentry 代码
  autoInstrumentServerFunctions: true,
  autoInstrumentClientComponents: false,

  // 隐藏 source maps(安全)
  hideSourceMaps: true,

  // 调整 bundle size 警告阈值
  tunnelRoute: '/monitoring',
});

20.9 错误监控仪表盘

健康检查端点

// app/api/health/route.ts

import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export const dynamic = 'force-dynamic';

export async function GET() {
  const checks: Record<string, { status: 'ok' | 'error'; latency?: number }> = {};

  // 数据库检查
  try {
    const start = Date.now();
    await prisma.$queryRaw`SELECT 1`;
    checks.database = {
      status: 'ok',
      latency: Date.now() - start,
    };
  } catch {
    checks.database = { status: 'error' };
  }

  // 应用状态
  const overallStatus = Object.values(checks).every((c) => c.status === 'ok')
    ? 'healthy'
    : 'unhealthy';

  return NextResponse.json(
    {
      status: overallStatus,
      timestamp: new Date().toISOString(),
      version: process.env.NEXT_PUBLIC_APP_VERSION || 'unknown',
      environment: process.env.NODE_ENV,
      checks,
    },
    {
      status: overallStatus === 'healthy' ? 200 : 503,
    }
  );
}

错误统计 API

// app/api/admin/error-stats/route.ts

import { NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth-utils';

export async function GET() {
  try {
    await requireRole('admin');
  } catch {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  // 这里可以对接你的错误日志存储(如 Loki、ClickHouse 等)
  return NextResponse.json({
    last24h: {
      totalErrors: 42,
      uniqueErrors: 15,
      affectedUsers: 28,
      topErrors: [
        { message: 'Failed to fetch articles', count: 12 },
        { message: 'Payment processing failed', count: 8 },
        { message: 'Image upload timeout', count: 5 },
      ],
    },
  });
}

20.10 生产监控清单

## 生产环境监控清单

### 错误监控
- [ ] Sentry 已集成(客户端 + 服务端 + Edge)
- [ ] error.tsx 在每个关键路由段配置
- [ ] global-error.tsx 已配置
- [ ] not-found.tsx 已自定义
- [ ] 错误上报过滤了 4xx 和无害错误

### 性能监控
- [ ] Sentry Performance 或 DataDog APM 已启用
- [ ] Core Web Vitals 已上报(web-vitals 库)
- [ ] 慢查询告警已配置
- [ ] API 响应时间监控已配置

### 健康检查
- [ ] /api/health 端点可访问
- [ ] 数据库连接检查已实现
- [ ] 外部服务依赖检查已实现
- [ ] Uptime 监控(如 UptimeRobot、Better Uptime)

### 日志系统
- [ ] 结构化日志(JSON 格式)
- [ ] 日志级别可通过环境变量控制
- [ ] 敏感信息(密码、Token)已过滤
- [ ] 请求追踪(X-Request-Id)已实现

### 告警
- [ ] 5xx 错误率告警(> 1%)
- [ ] P99 延迟告警(> 2s)
- [ ] 数据库连接失败告警
- [ ] 磁盘 / 内存使用率告警

### 安全
- [ ] 登录失败率监控
- [ ] API 限流监控
- [ ] 敏感数据泄露检测
- [ ] OWASP 漏洞扫描

### 业务指标
- [ ] 注册转化率
- [ ] 页面访问量
- [ ] API 调用量
- [ ] 服务器成本

本章小结

Key Takeaways

  1. 错误边界层次化:error.tsx 处理路由段错误,global-error.tsx 处理根布局错误,not-found.tsx 处理 404
  2. Server Action 必须返回结构化错误:不要 throw,而是返回 { success, error, code }
  3. Route Handler 用错误类封装ApiError 继承体系 + withErrorHandler 装饰器
  4. 结构化日志是生产环境的必需品:JSON 格式、统一字段、可查询
  5. Sentry 是错误监控的标准方案:自动捕获、Session Replay、性能监控
  6. 监控是一个持续过程:健康检查、告警、仪表盘缺一不可

下一步

下一章我们将深入 Docker 部署与 CI/CD——构建 Next.js 的完整部署流水线,包括 Docker 多阶段构建、GitHub Actions 自动化、Vercel / Docker / Cloudflare 多种部署方案。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页