diff --git a/env-example/.env.development.example b/env-example/.env.development.example index bc69877b..a8a59ec0 100644 --- a/env-example/.env.development.example +++ b/env-example/.env.development.example @@ -18,6 +18,13 @@ NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_RECEIVE_TIMEOUT=60000 NEXT_PUBLIC_API_SEND_TIMEOUT=30000 +# Sentry observability +NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=1 +NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0.1 +NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=1 +NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII=false +NEXT_PUBLIC_SENTRY_ENABLE_LOGS=true + # next-auth v4(Auth.js)—— JWT 签名密钥 # dev 环境独立密钥(**不**与 test / prod 共用) AUTH_SECRET=PinzhujxR/qMPrY7bAor8qQlwmmy1tJJCex9Tomhk2c= diff --git a/env-example/.env.local.example b/env-example/.env.local.example index dccf21c6..2202fb8c 100644 --- a/env-example/.env.local.example +++ b/env-example/.env.local.example @@ -11,6 +11,13 @@ NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_RECEIVE_TIMEOUT=60000 NEXT_PUBLIC_API_SEND_TIMEOUT=30000 +# Sentry observability +NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=1 +NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0.1 +NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=1 +NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII=false +NEXT_PUBLIC_SENTRY_ENABLE_LOGS=true + # next-auth v4(Auth.js)—— JWT 签名密钥 # test 环境独立密钥(**不**与 dev / prod 共用) AUTH_SECRET=rKGeHQ/8tKPoh12Kiedil5zHuj9ZfPA0NwY9bgwRqkE= diff --git a/env-example/.env.production.example b/env-example/.env.production.example index b5a7e624..cbc27555 100644 --- a/env-example/.env.production.example +++ b/env-example/.env.production.example @@ -19,6 +19,13 @@ NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_RECEIVE_TIMEOUT=60000 NEXT_PUBLIC_API_SEND_TIMEOUT=30000 +# Sentry observability +NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=0.1 +NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0.01 +NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=1 +NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII=false +NEXT_PUBLIC_SENTRY_ENABLE_LOGS=true + # next-auth v4(Auth.js)—— JWT 签名密钥 # production 环境独立密钥(**最**重要,**签**发**用**户 session cookie) AUTH_SECRET=XypsCWoLCNmwOaq4taIMJHe4+cU9YZBaYN4VMTtmSik= diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 80c0d0ef..01412f30 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -5,16 +5,6 @@ import * as Sentry from "@sentry/nextjs"; -Sentry.init({ - dsn: "https://a9390ae6d4b06c851854d48b070bb4cf@o4511511166713856.ingest.us.sentry.io/4511612532228096", +import { getSharedSentryOptions } from "@/utils/sentry-config"; - // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. - tracesSampleRate: 1, - - // Enable logs to be sent to Sentry - enableLogs: true, - - // Enable sending user PII (Personally Identifiable Information) - // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii - sendDefaultPii: true, -}); +Sentry.init(getSharedSentryOptions()); diff --git a/sentry.server.config.ts b/sentry.server.config.ts index c0fedd4f..476df6e9 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -4,16 +4,6 @@ import * as Sentry from "@sentry/nextjs"; -Sentry.init({ - dsn: "https://a9390ae6d4b06c851854d48b070bb4cf@o4511511166713856.ingest.us.sentry.io/4511612532228096", +import { getSharedSentryOptions } from "@/utils/sentry-config"; - // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. - tracesSampleRate: 1, - - // Enable logs to be sent to Sentry - enableLogs: true, - - // Enable sending user PII (Personally Identifiable Information) - // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii - sendDefaultPii: true, -}); +Sentry.init(getSharedSentryOptions()); diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts index 755f7d64..d0545ee3 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -3,29 +3,23 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; +import { + getSentryReplayOnErrorSampleRate, + getSentryReplaySessionSampleRate, + getSharedSentryOptions, +} from "@/utils/sentry-config"; Sentry.init({ - dsn: "https://a9390ae6d4b06c851854d48b070bb4cf@o4511511166713856.ingest.us.sentry.io/4511612532228096", + ...getSharedSentryOptions(), // Add optional integrations for additional features integrations: [Sentry.replayIntegration()], - // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. - tracesSampleRate: 1, - // Enable logs to be sent to Sentry - enableLogs: true, - // Define how likely Replay events are sampled. - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, + replaysSessionSampleRate: getSentryReplaySessionSampleRate(), // Define how likely Replay events are sampled when an error occurs. - replaysOnErrorSampleRate: 1.0, - - // Enable sending user PII (Personally Identifiable Information) - // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii - sendDefaultPii: true, + replaysOnErrorSampleRate: getSentryReplayOnErrorSampleRate(), }); export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/src/utils/__tests__/sentry-config.test.ts b/src/utils/__tests__/sentry-config.test.ts new file mode 100644 index 00000000..299b87a8 --- /dev/null +++ b/src/utils/__tests__/sentry-config.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + getSentryReplaySessionSampleRate, + getSharedSentryOptions, + readBoolean, + readSampleRate, +} from "../sentry-config"; + +describe("sentry config", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses conservative production defaults", () => { + vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production"); + + expect(getSharedSentryOptions()).toMatchObject({ + tracesSampleRate: 0.1, + enableLogs: true, + sendDefaultPii: false, + }); + expect(getSentryReplaySessionSampleRate()).toBe(0.01); + }); + + it("keeps non-production sampling high for local debugging", () => { + vi.stubEnv("NEXT_PUBLIC_APP_ENV", "development"); + + expect(getSharedSentryOptions().tracesSampleRate).toBe(1); + expect(getSentryReplaySessionSampleRate()).toBe(0.1); + }); + + it("allows valid environment overrides", () => { + vi.stubEnv("NEXT_PUBLIC_APP_ENV", "production"); + vi.stubEnv("NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE", "0.25"); + vi.stubEnv("NEXT_PUBLIC_SENTRY_ENABLE_LOGS", "false"); + vi.stubEnv("NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII", "true"); + vi.stubEnv("NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE", "0.05"); + + expect(getSharedSentryOptions()).toMatchObject({ + tracesSampleRate: 0.25, + enableLogs: false, + sendDefaultPii: true, + }); + expect(getSentryReplaySessionSampleRate()).toBe(0.05); + }); + + it("falls back for invalid sample rates and booleans", () => { + expect(readSampleRate("1.2", 0.1)).toBe(0.1); + expect(readSampleRate("-0.1", 0.1)).toBe(0.1); + expect(readSampleRate("abc", 0.1)).toBe(0.1); + expect(readSampleRate("0.5", 0.1)).toBe(0.5); + + expect(readBoolean("maybe", false)).toBe(false); + expect(readBoolean("yes", false)).toBe(true); + expect(readBoolean("off", true)).toBe(false); + }); +}); diff --git a/src/utils/sentry-config.ts b/src/utils/sentry-config.ts new file mode 100644 index 00000000..b3918d3c --- /dev/null +++ b/src/utils/sentry-config.ts @@ -0,0 +1,82 @@ +const SENTRY_DSN = + "https://a9390ae6d4b06c851854d48b070bb4cf@o4511511166713856.ingest.us.sentry.io/4511612532228096"; + +const PRODUCTION_TRACE_SAMPLE_RATE = 0.1; +const NON_PRODUCTION_TRACE_SAMPLE_RATE = 1; +const PRODUCTION_REPLAY_SESSION_SAMPLE_RATE = 0.01; +const NON_PRODUCTION_REPLAY_SESSION_SAMPLE_RATE = 0.1; +const DEFAULT_REPLAY_ON_ERROR_SAMPLE_RATE = 1; + +export interface SharedSentryOptions { + dsn: string; + tracesSampleRate: number; + enableLogs: boolean; + sendDefaultPii: boolean; +} + +export function getSharedSentryOptions(): SharedSentryOptions { + const isProduction = process.env.NEXT_PUBLIC_APP_ENV === "production"; + + return { + dsn: SENTRY_DSN, + tracesSampleRate: readSampleRate( + process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE ?? + process.env.SENTRY_TRACES_SAMPLE_RATE, + isProduction + ? PRODUCTION_TRACE_SAMPLE_RATE + : NON_PRODUCTION_TRACE_SAMPLE_RATE, + ), + enableLogs: readBoolean( + process.env.NEXT_PUBLIC_SENTRY_ENABLE_LOGS ?? + process.env.SENTRY_ENABLE_LOGS, + true, + ), + sendDefaultPii: readBoolean( + process.env.NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII ?? + process.env.SENTRY_SEND_DEFAULT_PII, + false, + ), + }; +} + +export function getSentryReplaySessionSampleRate(): number { + const isProduction = process.env.NEXT_PUBLIC_APP_ENV === "production"; + + return readSampleRate( + process.env.NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE, + isProduction + ? PRODUCTION_REPLAY_SESSION_SAMPLE_RATE + : NON_PRODUCTION_REPLAY_SESSION_SAMPLE_RATE, + ); +} + +export function getSentryReplayOnErrorSampleRate(): number { + return readSampleRate( + process.env.NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE, + DEFAULT_REPLAY_ON_ERROR_SAMPLE_RATE, + ); +} + +export function readSampleRate( + value: string | undefined, + fallback: number, +): number { + if (value === undefined || value.trim().length === 0) return fallback; + + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + if (parsed < 0 || parsed > 1) return fallback; + return parsed; +} + +export function readBoolean( + value: string | undefined, + fallback: boolean, +): boolean { + if (value === undefined || value.trim().length === 0) return fallback; + + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return fallback; +}