feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
export default defineNitroPlugin(async (nitroApp) => {
|
||||
// 预热 SSR 渲染器,避免首次请求因模块循环依赖导致的 500 错误
|
||||
// server.mjs 静态引用 renderer.mjs,renderer.mjs 动态引用 server.mjs,
|
||||
// 首次动态加载时 r.default 可能为 undefined,通过提前触发一次加载使缓存就绪
|
||||
try {
|
||||
await nitroApp.localFetch('/', { headers: { 'x-prerender-revalidate': 'true' } })
|
||||
} catch {
|
||||
// 预热失败是正常的(首次模块加载尚未完成),第一次真实请求时渲染器已就绪
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
export default defineNitroPlugin(async (nitroApp) => {
|
||||
|
||||
// 全局未处理的Promise拒绝处理器
|
||||
process.on('unhandledRejection', async (reason, promise) => {
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason)
|
||||
|
||||
// 检查是否是数据库连接错误
|
||||
if (reason && typeof reason === 'object' && 'message' in reason) {
|
||||
const errorMessage = (reason as Error).message
|
||||
|
||||
if (
|
||||
errorMessage.includes('ECONNRESET') ||
|
||||
errorMessage.includes('ENOTFOUND') ||
|
||||
errorMessage.includes('ETIMEDOUT') ||
|
||||
errorMessage.includes('Connection terminated') ||
|
||||
errorMessage.includes('Connection lost')
|
||||
) {
|
||||
console.log('Database connection error detected, attempting to reconnect...')
|
||||
|
||||
try {
|
||||
// 尝试重新连接数据库
|
||||
// 注意: Drizzle 没有显式的 connect/disconnect 方法
|
||||
// 连接由库自动管理
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)) // 等待2秒
|
||||
console.log('Database reconnection successful')
|
||||
} catch (reconnectError) {
|
||||
console.error('Database reconnection failed:', reconnectError)
|
||||
|
||||
// 如果重连失败,等待更长时间后再次尝试
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Note: Drizzle doesn't have explicit connect/disconnect methods
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000)) // 等待5秒
|
||||
console.log('Database delayed reconnection successful')
|
||||
} catch (delayedReconnectError) {
|
||||
console.error('Database delayed reconnection failed:', delayedReconnectError)
|
||||
}
|
||||
}, 10000) // 10秒后重试
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不要让进程退出,继续运行
|
||||
console.log('Process will continue running despite the error')
|
||||
})
|
||||
|
||||
// 全局未捕获异常处理器
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught Exception:', error)
|
||||
|
||||
// 检查是否是数据库相关错误
|
||||
if (
|
||||
error.message.includes('ECONNRESET') ||
|
||||
error.message.includes('ENOTFOUND') ||
|
||||
error.message.includes('ETIMEDOUT')
|
||||
) {
|
||||
console.log('Database-related uncaught exception, process will continue')
|
||||
return // 不退出进程
|
||||
}
|
||||
|
||||
// 对于其他严重错误,记录但不退出
|
||||
console.error('Non-database uncaught exception, process will continue')
|
||||
})
|
||||
|
||||
// 定期健康检查
|
||||
const healthCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1 as health_check`)
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error)
|
||||
|
||||
// 尝试重新连接
|
||||
try {
|
||||
// Note: Drizzle doesn't have explicit connect/disconnect methods
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
console.log('Health check reconnection successful')
|
||||
} catch (reconnectError) {
|
||||
console.error('Health check reconnection failed:', reconnectError)
|
||||
}
|
||||
}
|
||||
}, 60000) // 每分钟检查一次
|
||||
|
||||
// 在Nitro关闭时清理
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
clearInterval(healthCheckInterval)
|
||||
})
|
||||
|
||||
console.log('Global error handler initialized')
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { disconnectRedis, getRedisClient, isRedisConfigured } from '~~/server/utils/redis'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
if (isRedisConfigured()) {
|
||||
getRedisClient().catch((error) => {
|
||||
console.error('[Redis] 初始化短期状态服务失败:', error)
|
||||
})
|
||||
} else {
|
||||
console.log('[Redis] 未配置,将使用单实例内存短期状态')
|
||||
}
|
||||
|
||||
nitroApp.hooks.hook('close', async () => {
|
||||
await disconnectRedis()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
|
||||
|
||||
/**
|
||||
* 站点统计代码注入
|
||||
* 读取后台「站点配置」中保存的统计代码片段,注入服务端渲染页面(SSR)的 <head>。
|
||||
* 支持任意统计平台(百度统计、51.la、Microsoft Clarity、Google Analytics 等)的 HTML/JS 片段。
|
||||
* 仅在整页加载(SSR / 首次hydration)时触发,客户端路由切换不会重复执行。
|
||||
*
|
||||
* 采用模块级 TTL 缓存复用统一设置读取封装(getSystemSettingsCached),
|
||||
* 避免在渲染热路径上每次请求都查询数据库。
|
||||
* 读取失败时按关闭降级且不写缓存,下个请求重试。
|
||||
*/
|
||||
const CACHE_TTL_MS = 60 * 1000 // 60 秒
|
||||
|
||||
let cache: {
|
||||
enabled: boolean
|
||||
code: string
|
||||
} | null = null
|
||||
|
||||
let cacheFetchedAt = 0
|
||||
let inFlight: Promise<{ enabled: boolean; code: string }> | null = null
|
||||
|
||||
async function getStatisticsCodeConfig(): Promise<{
|
||||
enabled: boolean
|
||||
code: string
|
||||
}> {
|
||||
const now = Date.now()
|
||||
if (cache && now - cacheFetchedAt < CACHE_TTL_MS) {
|
||||
return cache
|
||||
}
|
||||
|
||||
if (!inFlight) {
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const settings = await getSystemSettingsCached()
|
||||
// getSystemSettingsCached 读取失败时返回 null(不抛错),按关闭降级且不写缓存,下个请求重试
|
||||
if (!settings) {
|
||||
return { enabled: false, code: '' }
|
||||
}
|
||||
cache = {
|
||||
enabled: !!settings.statisticsCodeEnabled,
|
||||
code: settings.statisticsCode || ''
|
||||
}
|
||||
cacheFetchedAt = Date.now()
|
||||
return cache
|
||||
} finally {
|
||||
inFlight = null
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return inFlight
|
||||
}
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('render:html', async (html: { head: string[] }) => {
|
||||
try {
|
||||
const { enabled, code } = await getStatisticsCodeConfig()
|
||||
if (enabled && code.trim() && Array.isArray(html.head)) {
|
||||
html.head.push(code)
|
||||
}
|
||||
} catch (error) {
|
||||
// 注入失败不影响页面渲染
|
||||
console.error('[StatisticsCode] 注入统计代码失败:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user