feat(sentry): Implement Sentry Tunnel to avoid local network permission dialog

- Add /api/sentry-tunnel API Route (Next.js)
- Update sentry.client.config.ts with tunnel option
- Re-enable NEXT_PUBLIC_SENTRY_DSN in CI/CD workflows

Resolves: #45 Sentry Tunnel
See: feedback_sentry_local_network.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-24 16:16:34 +08:00
parent cd7d63eeb1
commit b20987e7b6
4 changed files with 94 additions and 6 deletions

View File

@@ -185,9 +185,7 @@ jobs:
run: |
docker build --push \
--build-arg NEXT_PUBLIC_API_URL=https://awoooi.wooo.work \
# 暫時停用前端 Sentry (會觸發區域網路權限對話框)
# TODO: 實作 Sentry Tunnel 後再啟用
# --build-arg NEXT_PUBLIC_SENTRY_DSN=http://da02d4e5d6542e4d1ed6b2dd6542efeb@192.168.0.110:9000/2 \
--build-arg NEXT_PUBLIC_SENTRY_DSN=http://da02d4e5d6542e4d1ed6b2dd6542efeb@192.168.0.110:9000/2 \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web:${{ steps.tag.outputs.tag }} \
--file apps/web/Dockerfile .
echo "✅ Web: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web:${{ steps.tag.outputs.tag }}"

View File

@@ -148,9 +148,8 @@ jobs:
- name: Build packages
env:
NEXT_PUBLIC_API_URL: https://awoooi.wooo.work
# 暫時停用前端 Sentry (會觸發區域網路權限對話框)
# TODO: 實作 Sentry Tunnel 後再啟用
# NEXT_PUBLIC_SENTRY_DSN: http://da02d4e5d6542e4d1ed6b2dd6542efeb@192.168.0.110:9000/2
# Sentry DSN (透過 /api/sentry-tunnel 避免區域網路權限問題)
NEXT_PUBLIC_SENTRY_DSN: http://da02d4e5d6542e4d1ed6b2dd6542efeb@192.168.0.110:9000/2
run: pnpm turbo build
- name: Upload build artifacts

View File

@@ -14,6 +14,11 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Sentry Tunnel: 避免區域網路權限對話框
// @see apps/web/src/app/api/sentry-tunnel/route.ts
// @see feedback_sentry_local_network.md
tunnel: '/api/sentry-tunnel',
// 環境標識
environment: process.env.NODE_ENV,

View File

@@ -0,0 +1,86 @@
/**
* Sentry Tunnel API Route
* =======================
*
* 解決問題: 前端 Sentry DSN 使用內網 IP (192.168.0.110:9000) 會觸發
* 瀏覽器「存取區域網路上的其他裝置」權限對話框。
*
* 解決方案: 使用 Next.js API Route 作為 Tunnel前端透過公網域名
* (/api/sentry-tunnel) 發送事件,後端再轉發到內網 Sentry Server。
*
* 參考: https://docs.sentry.io/platforms/javascript/troubleshooting/#dealing-with-ad-blockers
*
* @see feedback_sentry_local_network.md
* @see project_sentry_full_integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
// Sentry Self-Hosted 內網地址
const SENTRY_HOST = 'http://192.168.0.110:9000';
// 允許的 Project IDs (防止濫用)
const ALLOWED_PROJECT_IDS = new Set(['2', '3']); // awoooi-web: 2, awoooi-api: 3
export async function POST(request: NextRequest) {
try {
const envelope = await request.text();
// 解析 envelope 取得 DSN 資訊
const [header] = envelope.split('\n');
const headerObj = JSON.parse(header);
const dsn = headerObj.dsn;
if (!dsn) {
return NextResponse.json(
{ error: 'Missing DSN in envelope header' },
{ status: 400 }
);
}
// 從 DSN 解析 Project ID
// DSN 格式: http://<key>@<host>:<port>/<project_id>
const projectId = dsn.split('/').pop();
if (!projectId || !ALLOWED_PROJECT_IDS.has(projectId)) {
return NextResponse.json(
{ error: 'Invalid or unauthorized project ID' },
{ status: 403 }
);
}
// 轉發到 Sentry Server
const response = await fetch(`${SENTRY_HOST}/api/${projectId}/envelope/`, {
method: 'POST',
body: envelope,
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
});
if (!response.ok) {
console.error('[Sentry Tunnel] Forward failed:', response.status, response.statusText);
return NextResponse.json(
{ error: 'Failed to forward to Sentry' },
{ status: response.status }
);
}
return new NextResponse(null, { status: 200 });
} catch (error) {
console.error('[Sentry Tunnel] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// 健康檢查端點
export async function GET() {
return NextResponse.json({
status: 'ok',
tunnel: '/api/sentry-tunnel',
target: SENTRY_HOST,
});
}