Commit 4e04aa08 by luoqi

Merge verified FridayAI Sentry integration into main

parents 3ac89085 6dfe2f1a
Pipeline #3752 failed in 0 seconds
......@@ -42,3 +42,13 @@ AI_INGRESS_URL=
AI_INGRESS_PULL_TOKEN=
# 不消费消息时设 off;CLI 自动关闭,不影响正常业务服务。
# AI_INGRESS_SUBSCRIBER=off
# 告警:测试/生产分别复用同环境 PAC 的 ALERT_WEBHOOK_URL;本地可留空。
AI_ALERT_WEBHOOK_URL=
# Sentry:同一组项目区分 test / production;留空则不上报。
AI_SENTRY_DSN=
# 由部署脚本仅将这个公开 DSN 传给 Web 构建。
NEXT_PUBLIC_AI_SENTRY_DSN=
# 原生本地运行时显式注入环境;Docker 发布由脚本设置环境和版本。
# SENTRY_ENVIRONMENT=development
......@@ -2,7 +2,7 @@
* Sentry 初始化 —— 必须在**任何其它模块之前**被 import(main.ts 首行),
* 这样 @sentry/node 的自动埋点(http / express 等)才能在库加载前挂上钩子。
*
* 与 pac-service 同源同配置(自部署 Sentry,只做 error 上报 + 基础 tracing)。
* 与 pac-service 同源同配置(自部署 Sentry,只做错误上报)。
* DSN 缺省 → 整体 no-op,本地无 Sentry 也能跑。
*
* ⚠️ 用**独立**的 `AI_SENTRY_DSN` 而不是 `SENTRY_DSN`:同仓开发时两个 app 的 env 会串,
......@@ -11,12 +11,13 @@
* ⚠️⚠️ **本文件读的是真·环境变量,读不到 `.env` 文件**。
* 它在 main.ts 首行被 import,那时 `ConfigModule.forRoot()` 还没执行(AppModule 尚未加载),
* 所以 dotenv 还没把 `.env` 灌进 process.env。
* ⇒ 生产没问题(systemd 的 `EnvironmentFile=` 是真环境变量);
* ⇒ 容器通过 Compose `env_file` 注入真实环境变量;
* **本地把 DSN 写进 `.env` 不会生效** —— 要本地验 Sentry 就 `AI_SENTRY_DSN=... pnpm dev`。
* ⛔ 不要为了修这个在这里 import dotenv:那会让加载顺序出现两个真理源,
* 而 Sentry 必须在**任何**模块之前初始化的约束比这点便利重要。
*/
import * as Sentry from '@sentry/nestjs';
import { scrubTelemetryEvent, scrubTelemetryBreadcrumb } from '@pac/utils';
const dsn = process.env.AI_SENTRY_DSN;
......@@ -25,7 +26,10 @@ if (dsn) {
dsn,
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? 'development',
release: process.env.SENTRY_RELEASE || undefined,
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
tracesSampleRate: 0,
sendDefaultPii: false,
beforeSend: scrubTelemetryEvent,
beforeBreadcrumb: scrubTelemetryBreadcrumb,
profilesSampleRate: 0,
});
}
......
......@@ -19,6 +19,12 @@ FROM deps AS build
COPY . .
RUN pnpm --workspace-concurrency=1 --filter '@pac/ai-web^...' build
WORKDIR /app/apps/ai-web
ARG NEXT_PUBLIC_AI_SENTRY_DSN
ARG NEXT_PUBLIC_AI_SENTRY_ENVIRONMENT
ARG NEXT_PUBLIC_AI_SENTRY_RELEASE
ENV NEXT_PUBLIC_AI_SENTRY_DSN=${NEXT_PUBLIC_AI_SENTRY_DSN} \
NEXT_PUBLIC_AI_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_AI_SENTRY_ENVIRONMENT} \
NEXT_PUBLIC_AI_SENTRY_RELEASE=${NEXT_PUBLIC_AI_SENTRY_RELEASE}
RUN pnpm build && mkdir -p public
FROM node:${NODE_VERSION} AS prod
......
import { withSentryConfig } from '@sentry/nextjs';
// 构建与开发进程统一 UTC;standalone 运行时由 Docker 镜像设置。
process.env.TZ = 'UTC';
......@@ -31,4 +33,12 @@ const nextConfig = {
},
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG ?? 'sentry',
project: 'friday-ai-web',
sentryUrl: 'https://am.jwsmed.com/',
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,
telemetry: false,
sourcemaps: { disable: !process.env.SENTRY_AUTH_TOKEN },
});
......@@ -28,7 +28,8 @@
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"@sentry/nextjs": "^10.66.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
......
'use client';
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="zh-CN">
<body>
<NextError statusCode={0} />
</body>
</html>
);
}
import * as Sentry from '@sentry/nextjs';
import { sentryOptions } from './lib/sentry';
if (sentryOptions.dsn) Sentry.init(sentryOptions);
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
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;
import { scrubTelemetryBreadcrumb, scrubTelemetryEvent } from '@pac/utils/telemetry';
export const sentryOptions = {
dsn: process.env.NEXT_PUBLIC_AI_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_AI_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
release: process.env.NEXT_PUBLIC_AI_SENTRY_RELEASE || undefined,
sendDefaultPii: false,
// 首次接入只采集错误;不采集性能事务、会话录屏或日志。
tracesSampleRate: 0,
beforeSend: scrubTelemetryEvent,
beforeBreadcrumb: scrubTelemetryBreadcrumb,
};
import * as Sentry from '@sentry/nextjs';
import { sentryOptions } from './lib/sentry';
if (sentryOptions.dsn) Sentry.init(sentryOptions);
import * as Sentry from '@sentry/nextjs';
import { sentryOptions } from './lib/sentry';
if (sentryOptions.dsn) Sentry.init(sentryOptions);
......@@ -25,6 +25,15 @@ main() {
fi
docker network inspect pac_default >/dev/null
export AI_IMAGE_TAG="$(git rev-parse --short HEAD)-$(date -u +%Y%m%dT%H%M%SZ)"
export AI_DEPLOY_ENVIRONMENT=production
[[ "$target" != staging ]] || export AI_DEPLOY_ENVIRONMENT=test
# 只读取公开 DSN;不 source 私密配置,也不把后端凭据传给前端构建。
export NEXT_PUBLIC_AI_SENTRY_DSN=''
if [[ -f apps/ai-service/.env ]]; then
while IFS='=' read -r key value || [[ -n "$key" ]]; do
[[ "$key" != NEXT_PUBLIC_AI_SENTRY_DSN ]] || export NEXT_PUBLIC_AI_SENTRY_DSN="${value%$'\r'}"
done < apps/ai-service/.env
fi
local compose=(docker compose -p pac-ai -f docker-compose.ai.yml)
[[ "${COMPOSE_MANAGED:-0}" != 1 ]] || compose+=(-f docker-compose.ai.managed.yml)
"${compose[@]}" config --quiet
......
......@@ -16,6 +16,7 @@ function runDeploy({
managed = false,
target = 'staging',
privateFail = false,
sentryEnv,
} = {}) {
const root = mkdtempSync(join(tmpdir(), 'ai-deploy-flow-'));
const bin = join(root, 'bin'),
......@@ -24,6 +25,10 @@ function runDeploy({
copyFileSync(source, join(root, 'deploy/deploy-ai.sh'));
copyFileSync(resolve('deploy/deploy-mode.sh'), join(root, 'deploy/deploy-mode.sh'));
writeFileSync(log, '');
if (sentryEnv !== undefined) {
mkdirSync(join(root, 'apps/ai-service'), { recursive: true });
writeFileSync(join(root, 'apps/ai-service/.env'), sentryEnv);
}
const common = 'printf "%s\\n" "$(basename "$0") $*" >> "$DEPLOY_TEST_LOG"\n';
const stubs = {
git: String.raw`case "$*" in
......@@ -35,7 +40,11 @@ function runDeploy({
ip: 'echo "1 via 172.16.4.1 dev eth0 src 172.16.4.239"',
curl: `[[ "$PRIVATE_FAIL" != 1 ]] || exit 7
printf '{"status":"ok","db":"up"}\\n200'`,
docker: String.raw`if [[ ( "$*" == *' run '* || "$*" == *' exec '* ) && "$*" != *'--interactive=false'* ]]; then cat >/dev/null; fi
docker: String.raw`if [[ "$*" == *' build ai-web' ]]; then
printf 'sentry-env %s %s\n' "$AI_DEPLOY_ENVIRONMENT" "$NEXT_PUBLIC_AI_SENTRY_DSN" >> "$DEPLOY_TEST_LOG"
[[ -z "$AI_DATABASE_URL" ]] || exit 91
fi
if [[ ( "$*" == *' run '* || "$*" == *' exec '* ) && "$*" != *'--interactive=false'* ]]; then cat >/dev/null; fi
if [[ -n "$DEPLOY_TEST_FAIL" && "$*" == *"$DEPLOY_TEST_FAIL"* ]]; then exit 43; fi
case "$*" in
*' ps -q ai-service') echo test-service ;;
......@@ -67,6 +76,7 @@ printf '{"status":"ok","db":"up"}\\n200'`,
...process.env,
PATH: `${bin}:${process.env.PATH}`,
DEPLOY_TEST_LOG: log,
AI_DATABASE_URL: '',
COMPOSE_MANAGED: typeof managed === 'string' ? managed : managed ? '1' : '0',
PRIVATE_FAIL: privateFail ? '1' : '0',
DEPLOY_TEST_FAIL: fail,
......@@ -172,3 +182,22 @@ test('AI 容器健康但私网不可达时不能报告部署完成', () => {
assert.match(result.stderr, /私网端点不可达/);
assert.doesNotMatch(result.stdout, /FridayAI 部署完成/);
});
for (const target of ['staging', 'production']) {
test(`Sentry 只传公开 DSN,不执行 env 内容,并区分环境 ${target}`, () => {
const { result, commands } = runDeploy({
target,
branch: target === 'staging' ? 'test' : 'main',
managed: target === 'production',
check: true,
sentryEnv:
'AI_DATABASE_URL=secret\nEXIT=$(exit 99)\nNEXT_PUBLIC_AI_SENTRY_DSN=https://public@example.com/46',
});
assert.equal(result.status, 0, result.stderr);
assert.ok(
commands.includes(
`sentry-env ${target === 'staging' ? 'test' : 'production'} https://public@example.com/46`,
),
);
});
}
......@@ -12,6 +12,8 @@ x-api: &api
format: raw
environment:
NODE_ENV: production
SENTRY_ENVIRONMENT: ${AI_DEPLOY_ENVIRONMENT:-production}
SENTRY_RELEASE: ${AI_IMAGE_TAG:-local}
TZ: UTC
AI_JWT_PRIVATE_KEY: /run/secrets/ai_jwt
NODE_OPTIONS: --max-old-space-size=1536
......@@ -61,6 +63,10 @@ services:
context: .
dockerfile: apps/ai-web/Dockerfile
target: prod
args:
NEXT_PUBLIC_AI_SENTRY_DSN: ${NEXT_PUBLIC_AI_SENTRY_DSN:-}
NEXT_PUBLIC_AI_SENTRY_ENVIRONMENT: ${AI_DEPLOY_ENVIRONMENT:-production}
NEXT_PUBLIC_AI_SENTRY_RELEASE: ${AI_IMAGE_TAG:-local}
restart: always
init: true
mem_limit: 512m
......
......@@ -139,6 +139,9 @@ AI_ECOM_ACCESS_ID=<生产凭据>
AI_ECOM_ACCESS_SECRET=<生产凭据>
AI_INGRESS_TOKEN=<ingress.key内容>
AI_TRUST_PROXY_HOPS=1
AI_SENTRY_DSN=<friday-ai-service项目DSN,项目47>
NEXT_PUBLIC_AI_SENTRY_DSN=<friday-ai-web项目DSN,项目46>
AI_ALERT_WEBHOOK_URL=<与生产PAC的ALERT_WEBHOOK_URL一致>
AI_LLM_API_KEY=<与测试共用的模型密钥>
AI_OUTBOX_WORKER=off
AI_SCHEDULER=off
......@@ -368,3 +371,5 @@ install -m 644 deploy/production/friday-ai.logrotate /etc/logrotate.d/friday-ai-
- 本次生产初始化不复用测试数据库、用户、企微设备或内部服务密钥;仅 OSS 和模型 API 凭据按已确认方案共用;不改既有 PAC 宿主授权,不部署自动回写服务。
交付记录:发布 SHA、各镜像标签/ID、RDS 备份 ID、迁移结果、域名/证书、开户/席位编号、功能开关、真实读取/隔离/消息补发验收、已知限制。未使用的步骤汇总能力已撤掉,不执行回填。凭据单独交付。
Sentry:仅接入 FridayAI 前后端,中转不接入。部署脚本自动区分 `test` / `production` 并记录镜像版本;公开前端 DSN 从 API 配置中单独读取用于构建,修改后需重新构建 Web。当前只上报错误,不开启性能采样、录屏;请求正文、请求头和用户信息不上报。没有配置上传凭据时不自动上传 source map,DSN 足够接收错误,但压缩栈的完整源码还原需另行配置。
......@@ -91,6 +91,9 @@ AI_ECOM_ACCESS_ID=<测试凭据>
AI_ECOM_ACCESS_SECRET=<测试凭据>
AI_INGRESS_TOKEN=<与中转INGRESS_TOKEN一致的随机密钥>
AI_TRUST_PROXY_HOPS=1
AI_SENTRY_DSN=<friday-ai-service项目DSN,项目47>
NEXT_PUBLIC_AI_SENTRY_DSN=<friday-ai-web项目DSN,项目46>
AI_ALERT_WEBHOOK_URL=<与测试PAC的ALERT_WEBHOOK_URL一致>
AI_LLM_API_KEY=<模型密钥>
AI_OUTBOX_WORKER=off
AI_SCHEDULER=off
......@@ -168,7 +171,7 @@ docker compose -p pac-ai -f docker-compose.ai.yml exec ai-service \
```sh
cd /root/pac
AI_IMAGE_TAG=$(cat "$(git rev-parse --git-common-dir)/ai-deploy/current-image-tag") \
docker compose -p pac-ai -f docker-compose.ai.yml up -d --no-deps --force-recreate --wait ai-service
AI_DEPLOY_ENVIRONMENT=test docker compose -p pac-ai -f docker-compose.ai.yml up -d --no-deps --force-recreate --wait ai-service
```
需要宿主内嵌时按[接入契约](../apps/pac-docs/content/docs/integration/friday-ai-host.mdx)登记应用和客户组织映射。
......@@ -207,3 +210,5 @@ bash deploy/deploy-ingress.sh staging --check
```
返回 JSON `status=ok`、退出码 0 为正常;`degraded` / 非 0 需检查对应项。测试保留现有本地调试路由;本地长时间离线也可能造成中转积压,不要因此清队列。生产部署和告警安装见 [生产部署](friday-ai-production-deployment.md)。
Sentry:仅接入 FridayAI 前后端,中转不接入。部署脚本自动区分 `test` / `production` 并记录镜像版本;公开前端 DSN 从 API 配置中单独读取用于构建,修改后需重新构建 Web。当前只上报错误,不开启性能采样、录屏;请求正文、请求头和用户信息不上报。没有配置上传凭据时不自动上传 source map,DSN 足够接收错误,但压缩栈的完整源码还原需另行配置。
# FridayAI Sentry 接入验证
- 范围:`friday-ai-web`(项目 46)、`friday-ai-service`(项目 47)。`channel-ingress` 不接入。
- 同一组项目通过 `environment=test/production` 区分环境,`release` 使用部署镜像标签。
- 只采集错误,不开启性能采样、录屏;去掉请求正文、请求头、用户对象、extra、控制台与点击面包屑,地址去除查询参数及片段。异常文本与堆栈保留,不应在业务异常中拼接敏感原文。
- DSN 写入服务器环境文件,不写入源码;生产只更新 `/data/pac-ops/readiness-20261010/config/ai-service.env.draft`,不部署生产应用。
- 复用 PAC 同环境的企微告警机器人;本次未手工发送企微消息。
## 本地验证
- utils 构建、AI 前后端类型检查通过。
- Web 使用独立 `.next-sentry-check` 目录完成生产构建,未覆盖开发产物。
- frozen lockfile 安装、Shell 语法、diff 空白检查通过。
- 部署脚本及脱敏测试 19 项通过,包括失败时不切换服务、环境区分、只提取公开 DSN、不执行 env 内容。
## 测试环境结果
- 已部署镜像标签:`1b1dd61-20261010T054414Z`;部署脚本退出码 0。
- API `/health/ready` 返回 `status=ok, db=up`,Web `/login` HTTP 200;74 条已有迁移全部应用,本次没有新迁移。
- 运行容器启用 Sentry,`environment=test`、`release` 与镜像标签一致;Web 静态脚本包含项目 46 的 DSN。
- 用运行容器的 SDK、环境和过滤函数向两个项目分别发送合成异常,均收到 HTTP 200、flush 成功:
- service:`d83ff4099561465ba76507f3fbd2753b`
- web:`401d47743ad542309de7f36397568686`
- 以上验证的是接收端成功接收;未登录 Sentry 看板确认展示,也未在真实浏览器中制造异常。Web envelope 接口对测试域名的 CORS 预检通过。
- PAC API 健康;PAC 前后端、中转容器的 ID、镜像、启动时间与部署前逐项一致。
- 配置及数据库备份:测试机 `/var/backups/friday-ai/sentry-20261010`;部署日志 `/var/tmp/ai-sentry-20261010/deploy.log`。
## 限制
- 本次没有提供 source map 上传凭据,未验证前端压缩堆栈的源码还原。
- Sentry 错误上报不替代进程存活、积压与业务告警。
......@@ -16,6 +16,10 @@
"./template": {
"types": "./dist/template.d.ts",
"default": "./dist/template.js"
},
"./telemetry": {
"types": "./dist/telemetry.d.ts",
"default": "./dist/telemetry.js"
}
},
"files": [
......
......@@ -41,3 +41,5 @@ export function chunk<T>(arr: T[], size: number): T[][] {
export async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export { scrubTelemetryEvent, scrubTelemetryBreadcrumb } from './telemetry';
// 仅保留错误定位所需信息;浏览器通过子路径导入,避免带入根模块的 node:crypto。
type Breadcrumb = { category?: string; message?: string; data?: Record<string, unknown> };
type TelemetryEvent = {
request?: {
url?: string;
data?: unknown;
cookies?: unknown;
headers?: unknown;
query_string?: unknown;
};
user?: unknown;
extra?: unknown;
breadcrumbs?: Breadcrumb[];
tags?: Record<string, unknown>;
};
function safeUrl(value: string): string {
try {
const u = new URL(value, 'http://placeholder');
const path = u.pathname
.split('/')
.map((part) =>
/^[\d]+$/.test(part) || /^[a-f\d-]{16,}$/i.test(part) || /[a-z].*\d{4}/i.test(part)
? ':id'
: part,
)
.join('/');
return (u.origin === 'http://placeholder' ? '' : u.origin) + path;
} catch {
return '[invalid-url]';
}
}
export function scrubTelemetryBreadcrumb<T extends Breadcrumb>(item: T): T | null {
// 控制台、点击和输入等面包屑可能携带消息正文或姓名。
if (item.category === 'fetch' || item.category === 'xhr' || item.category === 'http') {
return {
...item,
message: undefined,
data: {
method: item.data?.method,
status_code: item.data?.status_code,
url: typeof item.data?.url === 'string' ? safeUrl(item.data.url) : undefined,
},
};
}
if (item.category === 'navigation') {
return {
...item,
message: undefined,
data: {
from: typeof item.data?.from === 'string' ? safeUrl(item.data.from) : undefined,
to: typeof item.data?.to === 'string' ? safeUrl(item.data.to) : undefined,
},
};
}
return null;
}
export function scrubTelemetryEvent<T extends TelemetryEvent>(event: T): T {
delete event.user;
delete event.extra;
if (event.request) {
event.request = { url: event.request.url ? safeUrl(event.request.url) : undefined };
}
if (event.tags && typeof event.tags.path === 'string') {
const [method, path] = event.tags.path.split(' ', 2);
event.tags.path = path ? `${method} ${safeUrl(path)}` : safeUrl(method ?? '');
}
if (event.breadcrumbs) {
event.breadcrumbs = event.breadcrumbs.map(scrubTelemetryBreadcrumb).filter((b) => b !== null);
}
return event;
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scrubTelemetryEvent, scrubTelemetryBreadcrumb } from '../dist/telemetry.js';
test('错误保留堆栈并去除凭据、正文和用户信息', () => {
const event = {
exception: { values: [{ type: 'Error', value: 'sentry-smoke', stacktrace: { frames: [] } }] },
request: { url: 'https://fai.example/api/patients/123456?token=secret#secret', data: { text: 'private' }, headers: { authorization: 'secret' }, cookies: { session: 'secret' } },
user: { email: 'private@example.com' }, extra: { patientName: 'private' },
tags: { path: 'GET /api/patients/123456?token=secret', service: 'ai' },
};
const cleaned = scrubTelemetryEvent(event);
assert.deepEqual(cleaned.request, { url: 'https://fai.example/api/patients/:id' });
assert.equal(cleaned.tags.path, 'GET /api/patients/:id');
assert.equal(cleaned.exception.values[0].value, 'sentry-smoke');
assert.ok(!JSON.stringify(cleaned).includes('secret'));
assert.ok(!JSON.stringify(cleaned).includes('private'));
});
test('过滤控制台和点击内容,网络/导航只保留归一化地址', () => {
assert.equal(scrubTelemetryBreadcrumb({ category: 'console', message: 'private' }), null);
assert.equal(scrubTelemetryBreadcrumb({ category: 'ui.click', message: 'private' }), null);
const event = scrubTelemetryEvent({ breadcrumbs: [
{ category: 'navigation', data: { from: '/login?code=secret', to: '/conversations/123456#secret' } },
{ category: 'fetch', message: 'private', data: { url: 'https://username:password@fai.example/api/123456?x=secret', body: 'private', method: 'GET', status_code: 500 } },
{ category: 'console', message: 'private' },
] });
assert.equal(event.breadcrumbs.length, 2);
assert.equal(event.breadcrumbs[0].data.to, '/conversations/:id');
assert.equal(event.breadcrumbs[1].data.status_code, 500);
assert.ok(!/secret|private|password/.test(JSON.stringify(event)));
});
......@@ -187,6 +187,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.14)(react@19.2.5)
'@sentry/nextjs':
specifier: ^10.66.0
version: 10.66.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.106.0)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.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