266 lines
8.3 KiB
TypeScript
266 lines
8.3 KiB
TypeScript
import * as Sentry from '@sentry/node'
|
|
import { hostname } from 'node:os'
|
|
import type { H3Event } from 'h3'
|
|
import {
|
|
getSentryEventSearchText,
|
|
isExpectedUpstreamMusicError,
|
|
stringifyErrorValue
|
|
} from '~~/app/utils/sentryUpstreamMusicErrors'
|
|
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
|
import { getInstanceIdInfo } from '../utils/instance-id'
|
|
import { isTelemetryEnabled, isTelemetryEnabledCached } from '../utils/telemetry'
|
|
|
|
declare global {
|
|
var __voicehubSentryServerInitialized: boolean | undefined
|
|
var __voicehubSentryServerInitializing: Promise<boolean> | undefined
|
|
}
|
|
|
|
const getDeploymentTarget = (): string => {
|
|
if (process.env.NETLIFY === 'true') return 'netlify'
|
|
if (process.env.VERCEL) return 'vercel'
|
|
if (process.env.NITRO_PRESET?.includes('cloudflare')) return 'cloudflare'
|
|
return 'self-hosted-node'
|
|
}
|
|
|
|
const INSTANCE_ONLINE_TRANSACTION = 'voicehub.instance.online'
|
|
|
|
const EXPECTED_AGGREGATE_OAUTH_ERROR_PATTERNS = [
|
|
'聚合登陆配置缺失',
|
|
'聚合登陆接口地址配置错误',
|
|
'聚合登陆授权地址请求失败',
|
|
'聚合登陆授权地址获取失败',
|
|
'聚合登陆返回了无效的授权地址',
|
|
'OAuth state 解密失败'
|
|
]
|
|
|
|
const isExpectedAggregateOAuthError = (text: string): boolean => {
|
|
const normalizedText = text.toLowerCase()
|
|
return EXPECTED_AGGREGATE_OAUTH_ERROR_PATTERNS.some((pattern) =>
|
|
normalizedText.includes(pattern.toLowerCase())
|
|
)
|
|
}
|
|
|
|
// 邮件服务不可用(多为实例 SMTP 配置问题)降级为 info,保留记录但不触发告警
|
|
const isEmailServiceConfigError = (error: unknown): boolean => {
|
|
if (!error || typeof error !== 'object') return false
|
|
const record = error as {
|
|
statusMessage?: unknown
|
|
data?: { code?: unknown } | undefined
|
|
}
|
|
const dataCode = typeof record.data?.code === 'string' ? record.data.code : ''
|
|
return (
|
|
record.statusMessage === SERVER_ERROR_CODES.AUTH_EMAIL_SERVICE_UNAVAILABLE ||
|
|
dataCode === SERVER_ERROR_CODES.AUTH_EMAIL_SERVICE_UNAVAILABLE
|
|
)
|
|
}
|
|
|
|
const shouldCaptureServerError = (error: unknown): boolean => {
|
|
if (!error || typeof error !== 'object') return true
|
|
const maybeStatusCode = (error as { statusCode?: unknown }).statusCode
|
|
const statusCode = typeof maybeStatusCode === 'number' ? maybeStatusCode : undefined
|
|
|
|
// Drop expected business/auth errors (4xx) to avoid Sentry noise.
|
|
if (statusCode && statusCode >= 400 && statusCode < 500) {
|
|
return false
|
|
}
|
|
|
|
// 外部音源接口波动属于预期失败,本地保留日志和接口返回即可。
|
|
const errorText = stringifyErrorValue(error)
|
|
if (isExpectedUpstreamMusicError(errorText) || isExpectedAggregateOAuthError(errorText)) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
const buildRequestContext = (event: H3Event) => {
|
|
const rawUrl = event.node.req.url || ''
|
|
// 移除 querystring,避免把敏感信息(如 token)泄露到 Sentry
|
|
const pathname = rawUrl.split('?')[0] || rawUrl
|
|
|
|
return {
|
|
method: event.node.req.method || 'UNKNOWN',
|
|
url: pathname,
|
|
requestId: event.context.requestId || '',
|
|
headers: {
|
|
host: event.node.req.headers.host || '',
|
|
userAgent: event.node.req.headers['user-agent'] || ''
|
|
}
|
|
}
|
|
}
|
|
|
|
const captureInstanceOnlineTransaction = (instanceId: string, deploymentTarget: string) => {
|
|
Sentry.startSpan(
|
|
{
|
|
name: INSTANCE_ONLINE_TRANSACTION,
|
|
op: 'app.startup',
|
|
forceTransaction: true,
|
|
parentSpan: null,
|
|
attributes: {
|
|
'voicehub.event_type': 'instance_online',
|
|
'voicehub.instance_id': instanceId,
|
|
'deployment.target': deploymentTarget,
|
|
'runtime.name': 'nuxt',
|
|
'nitro.preset': process.env.NITRO_PRESET || 'node-server'
|
|
}
|
|
},
|
|
() => {}
|
|
)
|
|
}
|
|
|
|
export default defineNitroPlugin((nitroApp) => {
|
|
const config = useRuntimeConfig()
|
|
const jwtSecret = process.env.JWT_SECRET || config.jwtSecret
|
|
if (!jwtSecret) {
|
|
// 配置错误不进入遥测链路,避免在认证未初始化时泄露启动上下文
|
|
throw new Error(
|
|
'JWT_SECRET is required. Set it to a random string for local development.'
|
|
)
|
|
}
|
|
|
|
const sentryConfig = config.sentry
|
|
|
|
if (!sentryConfig?.enabled || !sentryConfig.dsn) {
|
|
return
|
|
}
|
|
|
|
const ensureSentryInitialized = async (): Promise<boolean> => {
|
|
if (globalThis.__voicehubSentryServerInitialized) {
|
|
return true
|
|
}
|
|
|
|
if (globalThis.__voicehubSentryServerInitializing) {
|
|
return globalThis.__voicehubSentryServerInitializing
|
|
}
|
|
|
|
globalThis.__voicehubSentryServerInitializing = (async () => {
|
|
const telemetryEnabled = await isTelemetryEnabled()
|
|
if (!telemetryEnabled) {
|
|
return false
|
|
}
|
|
|
|
let instanceId = ''
|
|
let instanceIdPersisted = false
|
|
|
|
try {
|
|
const instanceInfo = await getInstanceIdInfo()
|
|
instanceId = instanceInfo.instanceId
|
|
instanceIdPersisted = instanceInfo.persisted
|
|
} catch (error) {
|
|
console.warn('[Sentry] Failed to resolve instance ID for server tagging:', error)
|
|
}
|
|
|
|
const deploymentTarget = getDeploymentTarget()
|
|
|
|
Sentry.init({
|
|
dsn: sentryConfig.dsn,
|
|
environment: sentryConfig.environment,
|
|
release: sentryConfig.release || undefined,
|
|
// 覆盖 SDK 自动采集的容器 hostname:由 SENTRY_SERVER_NAME 指定,未设置时回退到 os.hostname()
|
|
serverName: process.env.SENTRY_SERVER_NAME || hostname(),
|
|
integrations: [
|
|
Sentry.consoleLoggingIntegration({
|
|
levels: process.env.NODE_ENV === 'development' ? ['log', 'warn', 'error'] : ['error']
|
|
})
|
|
],
|
|
enableLogs: true,
|
|
tracesSampler(samplingContext) {
|
|
return samplingContext.name === INSTANCE_ONLINE_TRANSACTION
|
|
? 1
|
|
: samplingContext.inheritOrSampleWith(sentryConfig.tracesSampleRate)
|
|
},
|
|
sendDefaultPii: false,
|
|
beforeSend(event, hint) {
|
|
if (!isTelemetryEnabledCached()) {
|
|
return null
|
|
}
|
|
|
|
const eventText = getSentryEventSearchText(event, hint)
|
|
if (isExpectedUpstreamMusicError(eventText) || isExpectedAggregateOAuthError(eventText)) {
|
|
return null
|
|
}
|
|
|
|
return event
|
|
},
|
|
beforeSendLog(log) {
|
|
if (!isTelemetryEnabledCached()) {
|
|
return null
|
|
}
|
|
|
|
const logText = stringifyErrorValue(log)
|
|
if (isExpectedUpstreamMusicError(logText) || isExpectedAggregateOAuthError(logText)) {
|
|
return null
|
|
}
|
|
|
|
return log
|
|
},
|
|
beforeSendTransaction(event) {
|
|
return isTelemetryEnabledCached() ? event : null
|
|
}
|
|
})
|
|
|
|
globalThis.__voicehubSentryServerInitialized = true
|
|
|
|
Sentry.setTag('runtime', 'nuxt')
|
|
Sentry.setTag('deployment_target', deploymentTarget)
|
|
Sentry.setTag('nitro_preset', process.env.NITRO_PRESET || 'node-server')
|
|
if (instanceId) {
|
|
Sentry.setTag('instance_id', instanceId)
|
|
Sentry.setTag('instance_id_persisted', String(instanceIdPersisted))
|
|
Sentry.setContext('instance', {
|
|
instanceId,
|
|
persisted: instanceIdPersisted
|
|
})
|
|
}
|
|
|
|
if (instanceIdPersisted && instanceId) {
|
|
captureInstanceOnlineTransaction(instanceId, deploymentTarget)
|
|
} else {
|
|
console.warn(
|
|
'[Sentry] Skipped instance startup transaction because instance ID was not persisted.'
|
|
)
|
|
}
|
|
|
|
return true
|
|
})().finally(() => {
|
|
globalThis.__voicehubSentryServerInitializing = undefined
|
|
})
|
|
|
|
return globalThis.__voicehubSentryServerInitializing
|
|
}
|
|
|
|
nitroApp.hooks.hook('error', async (error, context) => {
|
|
if (!shouldCaptureServerError(error)) {
|
|
return
|
|
}
|
|
|
|
const initialized = await ensureSentryInitialized()
|
|
if (!initialized) {
|
|
return
|
|
}
|
|
|
|
const level = isEmailServiceConfigError(error) ? 'info' : 'error'
|
|
|
|
Sentry.withScope((scope) => {
|
|
const event = context?.event
|
|
if (event) {
|
|
scope.setContext('request', buildRequestContext(event))
|
|
if (event.context.requestId) {
|
|
scope.setTag('request_id', event.context.requestId)
|
|
}
|
|
}
|
|
|
|
scope.setLevel(level)
|
|
Sentry.captureException(error)
|
|
})
|
|
})
|
|
|
|
nitroApp.hooks.hook('close', async () => {
|
|
if (globalThis.__voicehubSentryServerInitialized) {
|
|
await Sentry.close(2000)
|
|
}
|
|
})
|
|
|
|
void ensureSentryInitialized()
|
|
})
|