Commit 640ba3f9 by luoqi

feat: 接入自部署 Sentry(前后端分开,错误+基础tracing)

自部署 Sentry 23.6.1(am.jwsmed.com),两个项目/DSN/SDK,环境用 SENTRY_ENVIRONMENT 区分(test/prod)。
已冒烟验证:老服务器接受现代 SDK v10 envelope(flush=true);pac-web next build(Turbopack)通过;pac-service 启动正常。

后端 pac-service(@sentry/nestjs):
- src/instrument.ts:Sentry.init(DSN 缺省则 no-op),main.ts 首行 import;profiling 关。
- app.module 注册 SentryModule.forRoot()(请求级 tracing)。
- AllExceptionsFilter 仅对真·500(instanceof Error 分支)captureException,业务错误不上报。

前端 pac-web(@sentry/nextjs,Next16/App Router):
- instrumentation-client.ts / sentry.server.config.ts / sentry.edge.config.ts / instrumentation.ts。
- next.config.mjs 用 withSentryConfig 包裹;sentryUrl 指自部署;source map 上传仅 SENTRY_AUTH_TOKEN 存在时(secret,不入库);telemetry 关;不开 Replay。

env:两端 .env.example 补 Sentry 段;DSN 走 env,真值在各机 .env(gitignored),auth token 仅构建期 env。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 8217f5ab
...@@ -195,3 +195,13 @@ PAC_WEB_BASE_URL=http://localhost:3100 ...@@ -195,3 +195,13 @@ PAC_WEB_BASE_URL=http://localhost:3100
# [DEV] 开启「主动推送」演示端点 POST /pac/v1/internal/wx-aibot/push-urgent(=1 放行)。 # [DEV] 开启「主动推送」演示端点 POST /pac/v1/internal/wx-aibot/push-urgent(=1 放行)。
# ⚠️ 生产请关闭/移除,真实触发走定时扫描或事件驱动,不暴露裸 HTTP 端点。 # ⚠️ 生产请关闭/移除,真实触发走定时扫描或事件驱动,不暴露裸 HTTP 端点。
PAC_WX_AIBOT_DEMO_PUSH= PAC_WX_AIBOT_DEMO_PUSH=
# ── Sentry(自部署 am.jwsmed.com,project=pac-service)────────────────
# 缺省 → 不初始化 Sentry(本地无需)。测试服务器填 DSN + SENTRY_ENVIRONMENT=test。
SENTRY_DSN=
# 环境区分:test(测试服务器)/ production(生产);缺省回退 NODE_ENV
SENTRY_ENVIRONMENT=
# 性能 tracing 采样率(0~1),默认 0.1;设 0 = 只报错误
SENTRY_TRACES_SAMPLE_RATE=0.1
# release(可选):部署时注入 git SHA
SENTRY_RELEASE=
...@@ -67,15 +67,16 @@ ...@@ -67,15 +67,16 @@
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",
"@nestjs/websockets": "^11.1.24", "@nestjs/websockets": "^11.1.24",
"@pac/types": "workspace:*", "@pac/types": "workspace:*",
"cron": "^4.4.0",
"@pac/utils": "workspace:*", "@pac/utils": "workspace:*",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@scalar/nestjs-api-reference": "^1.1.14", "@scalar/nestjs-api-reference": "^1.1.14",
"@sentry/nestjs": "^10.66.0",
"@wecom/aibot-node-sdk": "^1.0.7", "@wecom/aibot-node-sdk": "^1.0.7",
"ai": "^6.0.184", "ai": "^6.0.184",
"bullmq": "^5.76.10", "bullmq": "^5.76.10",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cron": "^4.4.0",
"csv-parse": "^6.2.1", "csv-parse": "^6.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
......
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SentryModule } from '@sentry/nestjs/setup';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
...@@ -30,6 +31,8 @@ import { HealthController } from './health.controller'; ...@@ -30,6 +31,8 @@ import { HealthController } from './health.controller';
@Module({ @Module({
imports: [ imports: [
// Sentry:注册在最前(官方要求),配合 main.ts 首行的 ./instrument 提供请求级 tracing。
SentryModule.forRoot(),
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [loadConfig], load: [loadConfig],
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import * as Sentry from '@sentry/nestjs';
import { ApiCode, describeApiCode } from '@pac/types'; import { ApiCode, describeApiCode } from '@pac/types';
import { ZodValidationException } from 'nestjs-zod'; import { ZodValidationException } from 'nestjs-zod';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
...@@ -84,6 +85,11 @@ export class AllExceptionsFilter implements ExceptionFilter { ...@@ -84,6 +85,11 @@ export class AllExceptionsFilter implements ExceptionFilter {
code = ApiCode.INTERNAL_ERROR; code = ApiCode.INTERNAL_ERROR;
msg = exception.message || describeApiCode(code); msg = exception.message || describeApiCode(code);
this.logger.error(exception.stack ?? msg); this.logger.error(exception.stack ?? msg);
// 只上报"真·未预期错误"(500 分支)给 Sentry;业务错误(BizError/Zod/Http)不上报,免噪音。
// captureException 在未 init(无 DSN)时是 no-op,本地无 Sentry 也安全。
Sentry.captureException(exception, {
tags: { path: `${req.method} ${req.url}` },
});
} }
if (httpStatus >= 500) { if (httpStatus >= 500) {
......
/**
* Sentry 初始化 —— 必须在**任何其它模块之前**被 import(main.ts 首行),
* 这样 @sentry/node 的自动埋点(http / express 等)才能在库加载前挂上钩子。
*
* 自部署 Sentry 23.6.1(am.jwsmed.com):只做 error 上报 + 基础 tracing,
* 不开 profiling / replay(老服务器不支持/不全)。DSN 缺省 → 整体 no-op(本地无 Sentry 也能跑)。
*/
import * as Sentry from '@sentry/nestjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
// 环境区分:测试服务器 SENTRY_ENVIRONMENT=test,生产=production;缺省回退 NODE_ENV
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? 'development',
// release 绑部署版本(可选,部署时注入 git SHA)
release: process.env.SENTRY_RELEASE || undefined,
// 性能 tracing 采样率(默认 10%);设 0 = 只报错误不报性能
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
// 老服务器:显式不开这两个
profilesSampleRate: 0,
});
}
export const sentryEnabled = !!dsn;
import './instrument'; // ⚠️ 必须首行:Sentry 自动埋点需在其它模块加载前初始化
import 'reflect-metadata'; import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
......
NEXT_PUBLIC_API_BASE_URL=http://localhost:3101 NEXT_PUBLIC_API_BASE_URL=http://localhost:3101
# ── Sentry(自部署 am.jwsmed.com,project=pac-web)──────────────────────
# 前端 DSN 会内联进 bundle(公开可见,设计如此)。缺省 → 不初始化。
# 需在**构建期**存在(Dockerfile 是 pnpm build),故写进本文件对应的 .env。
NEXT_PUBLIC_SENTRY_DSN=
# 环境区分:test / production;缺省回退 NODE_ENV
NEXT_PUBLIC_SENTRY_ENVIRONMENT=
NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=0.1
# ↓ 仅构建期上传 source map 用(让线上堆栈可读);SENTRY_AUTH_TOKEN 是 secret,勿入库。
# 缺省则跳过上传、构建照常成功(线上堆栈是压缩后的)。
SENTRY_ORG=sentry
SENTRY_PROJECT=pac-web
SENTRY_URL=https://am.jwsmed.com/
SENTRY_AUTH_TOKEN=
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
...@@ -28,4 +30,15 @@ const nextConfig = { ...@@ -28,4 +30,15 @@ const nextConfig = {
}, },
}; };
export default nextConfig; // Sentry 包装:注入客户端/服务端埋点 + (可选)构建期上传 source map 到自部署 Sentry。
// 自部署实例:sentryUrl 指 am.jwsmed.com;org/project 可用 env 覆盖。
// source map 上传仅在 SENTRY_AUTH_TOKEN 存在时进行(构建期 secret,不入库);无 token 构建照样成功。
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG ?? 'sentry',
project: process.env.SENTRY_PROJECT ?? 'pac-web',
sentryUrl: process.env.SENTRY_URL ?? 'https://am.jwsmed.com/',
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,
telemetry: false, // 自部署:不向 sentry.io 发遥测
sourcemaps: { disable: !process.env.SENTRY_AUTH_TOKEN },
});
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@sentry/nextjs": "^10.66.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
......
/**
* Sentry 浏览器端初始化(Next 15.3+ 用 instrumentation-client.ts)。
* DSN 走 NEXT_PUBLIC_SENTRY_DSN(构建期内联进 bundle;前端 DSN 公开是设计如此)。
* 缺省 DSN → no-op(本地无 Sentry 也能跑)。
* 自部署 Sentry 23.6.1:只做 error + 基础 tracing,不开 Session Replay(老服务器不全)。
*/
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
});
}
// App Router 客户端路由跳转埋点(Sentry v8+ 要求显式导出)
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
/**
* Next.js instrumentation 钩子 —— 按 runtime 加载对应 Sentry 服务端配置,
* 并导出 onRequestError 捕获 SSR / RSC / Route Handler 里的服务端错误。
*/
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;
/**
* Sentry Next.js Edge runtime 初始化(middleware / edge routes)。经 register() 加载。
* pac-web 目前基本不用 edge,但 Next 约定齐全一份,避免 edge 报错逃逸。缺省 DSN → no-op。
*/
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
});
}
/**
* Sentry Next.js 服务端(Node runtime)初始化。经 instrumentation.ts 的 register() 加载。
* 复用同一个 pac-web 项目 DSN(NEXT_PUBLIC_SENTRY_DSN);缺省 → no-op。
*/
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment