Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,7 @@ jobs:

- name: Build
run: pnpm build

- name: Verify compiled server instrumentation
run: |
node -e 'const hook = require("./.next/server/instrumentation.js"); if (typeof hook.register !== "function" || typeof hook.onRequestError !== "function") process.exit(1)'
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ concurrency:

jobs:
e2e-tests:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30

services:
Expand Down
15 changes: 0 additions & 15 deletions instrumentation.ts

This file was deleted.

17 changes: 1 addition & 16 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,32 +324,17 @@ const withBundleAnalyzer = NextBundleAnalyzer({
const analyzedConfig = withBundleAnalyzer(nextConfig)

const sentryBuildOptions = {
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/build/

org: 'hexelnet',
project: 'emuready',

// Only print logs for uploading source maps in CI
release: { name: nextBuildId || undefined },
silent: !process.env.CI,

// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,

sourcemaps: {
disable: !isVercelBuild,
},

// Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
// tunnelRoute: "/monitoring",

bundleSizeOptimizations: {
excludeDebugStatements: true,
},

telemetry: false,
}

Expand Down
22 changes: 0 additions & 22 deletions sentry.edge.config.ts

This file was deleted.

19 changes: 6 additions & 13 deletions sentry.server.config.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/

import * as Sentry from '@sentry/nextjs'
import { env } from '@/lib/env'

// Only enable Sentry when explicitly configured for this deployment.
if (process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true') {
Sentry.init({
dsn: 'https://85ca585e45005d8786e361c3456518bf@o74828.ingest.us.sentry.io/4509717207318529',

// 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
environment: env.APP_ENV,
...(process.env.APP_VERSION ? { release: process.env.APP_VERSION } : {}),
sampleRate: 1,
tracesSampleRate: 0.1,
enableLogs: true,

// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
sendDefaultPii: false,
})
}
11 changes: 4 additions & 7 deletions src/app/api/mobile/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { connection, type NextRequest, NextResponse } from 'next/server'
import { getCORSHeaders } from '@/lib/cors'
import { logger } from '@/lib/logger'
import { createMobileTRPCFetchContext } from '@/server/api/mobileContext'
import { mobileRouter } from '@/server/api/routers/mobile'
import { getTRPCResponseCacheHeaders, TRPC_PRIVATE_CACHE_CONTROL } from '@/server/api/trpc-cache'
import { reportTRPCError } from '@/server/api/trpc-errors'

// Get CORS headers with additional tRPC headers
function getTRPCCorsHeaders(request: NextRequest) {
Expand Down Expand Up @@ -68,12 +70,7 @@ const handler = async (req: NextRequest) => {
req: correctedRequest,
router: mobileRouter,
createContext: createMobileTRPCFetchContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }: { path?: string; error: Error }) => {
console.error(`❌ Mobile tRPC failed on ${path ?? '<no-path>'}: ${error.message}`)
}
: undefined,
onError: (options) => reportTRPCError(options.error, options.path),
responseMeta(opts) {
return {
headers: {
Expand Down Expand Up @@ -102,7 +99,7 @@ const handler = async (req: NextRequest) => {
return response
} catch (error) {
// If anything fails, return an error response with CORS headers
console.error('Mobile tRPC handler error:', error)
logger.error('[Mobile tRPC] Handler error', error)

// Server-side error filtering - don't expose details in production
const errorMessage =
Expand Down
8 changes: 2 additions & 6 deletions src/app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@ import { connection, type NextRequest } from 'next/server'
import { appRouter } from '@/server/api/root'
import { createAppRouterTRPCContext } from '@/server/api/trpc'
import { getTRPCResponseCacheHeaders } from '@/server/api/trpc-cache'
import { reportTRPCError } from '@/server/api/trpc-errors'

const handler = async (req: NextRequest) => {
return fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createAppRouterTRPCContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => {
console.error(`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`)
}
: undefined,
onError: (options) => reportTRPCError(options.error, options.path),
responseMeta(opts) {
return {
headers: getTRPCResponseCacheHeaders({
Expand Down
16 changes: 5 additions & 11 deletions src/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/

import * as Sentry from '@sentry/nextjs'
import { env } from '@/lib/env'

if (env.ENABLE_SENTRY) {
Sentry.init({
dsn: 'https://85ca585e45005d8786e361c3456518bf@o74828.ingest.us.sentry.io/4509717207318529',

environment: env.APP_ENV,
Comment thread
Producdevity marked this conversation as resolved.
Resolved
integrations: [Sentry.replayIntegration()],

tracesSampleRate: 1,
sampleRate: 1,
tracesSampleRate: 0.1,
enableLogs: true,

sendDefaultPii: false,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,

debug: false,
replaysOnErrorSampleRate: 1,

beforeSend(event, hint) {
const error = hint?.originalException
Expand Down
70 changes: 70 additions & 0 deletions src/instrumentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
init: vi.fn(),
captureRequestError: vi.fn(),
initializeServerOnce: vi.fn(),
}))

vi.mock('@sentry/nextjs', () => ({
init: mocks.init,
captureRequestError: mocks.captureRequestError,
}))
vi.mock('./server/init', () => ({ initializeServerOnce: mocks.initializeServerOnce }))

afterEach(() => {
vi.unstubAllEnvs()
vi.resetModules()
vi.clearAllMocks()
})

async function loadInstrumentation(runtime: string, enabled = true) {
vi.resetModules()
vi.stubEnv('NEXT_RUNTIME', runtime)
vi.stubEnv('NEXT_PUBLIC_ENABLE_SENTRY', String(enabled))
vi.stubEnv('NEXT_PHASE', '')
return import('./instrumentation')
}

describe('server instrumentation entry point', () => {
it('initializes Sentry before starting Node server services', async () => {
const instrumentation = await loadInstrumentation('nodejs')
await instrumentation.register()

expect(mocks.init).toHaveBeenCalledTimes(1)
expect(mocks.initializeServerOnce).toHaveBeenCalledTimes(1)
expect(mocks.init.mock.invocationCallOrder[0]).toBeLessThan(
mocks.initializeServerOnce.mock.invocationCallOrder[0] ?? 0,
)
})

it('does not initialize Node services in an unsupported runtime', async () => {
const instrumentation = await loadInstrumentation('edge')
await instrumentation.register()

expect(mocks.init).not.toHaveBeenCalled()
expect(mocks.initializeServerOnce).not.toHaveBeenCalled()
})

it('starts services with Sentry disabled', async () => {
const instrumentation = await loadInstrumentation('nodejs', false)
await instrumentation.register()

expect(mocks.init).not.toHaveBeenCalled()
expect(mocks.initializeServerOnce).toHaveBeenCalledTimes(1)
})

it('skips runtime initialization during the production build', async () => {
const instrumentation = await loadInstrumentation('nodejs')
vi.stubEnv('NEXT_PHASE', 'phase-production-build')
await instrumentation.register()

expect(mocks.init).not.toHaveBeenCalled()
expect(mocks.initializeServerOnce).not.toHaveBeenCalled()
})

it('exports the Sentry server-request error handler', async () => {
const instrumentation = await loadInstrumentation('nodejs')
expect(instrumentation).toHaveProperty('onRequestError', mocks.captureRequestError)
})
})
9 changes: 5 additions & 4 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import * as Sentry from '@sentry/nextjs'
import { PHASE_PRODUCTION_BUILD } from 'next/constants'

export async function register() {
if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) return

if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('../sentry.server.config')
}

if (process.env.NEXT_RUNTIME === 'edge') {
await import('../sentry.edge.config')
const { initializeServerOnce } = await import('./server/init')
await initializeServerOnce()
Comment thread
Producdevity marked this conversation as resolved.
Resolved
}
}

Expand Down
47 changes: 47 additions & 0 deletions src/lib/app-error-cause.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,51 @@ describe('getSerializableAppError', () => {
expect(getSerializableAppError('nope')).toBeNull()
expect(getSerializableAppError(42)).toBeNull()
})

it.each(['internal', 'database'] as const)(
'does not serialize raw provider errors passed to %s failures',
(kind) => {
const providerError = Object.assign(new Error('Provider failed'), {
code: 'P2022',
meta: { column: 'private_column' },
})
const error = captureTrpcError(() => {
if (kind === 'database') AppError.databaseError('test operation', providerError)
AppError.internalError('Operation failed', providerError)
})

expect(error.cause).toBe(providerError)
expect(roundTripAppError(error)).toBeNull()
},
)

it('preserves submission-limit details for the client', () => {
const error = captureTrpcError(() => ResourceError.game.submissionLimitExceeded(10, 3, 2))

expect(roundTripAppError(error)).toEqual({
code: APP_ERROR_CODES.GAME_SUBMISSION_LIMIT_EXCEEDED,
gamesCount: 10,
listingsCount: 3,
buffer: 2,
reportsNeeded: 6,
})
})

it.each(['internal', 'database'] as const)(
'keeps %s diagnostics private while preserving the reported Error identity',
(kind) => {
const providerError = Object.assign(new Error('Provider failed'), {
code: 'PRIVATE_PROVIDER',
})
const diagnosticError = new Error('Operation failed', { cause: providerError })
const error = captureTrpcError(() => {
if (kind === 'database') AppError.databaseError('test operation', diagnosticError)
AppError.internalError('Operation failed', diagnosticError)
})

expect(error.cause).toBe(diagnosticError)
expect(diagnosticError.cause).toBe(providerError)
expect(roundTripAppError(error)).toBeNull()
},
)
})
7 changes: 4 additions & 3 deletions src/lib/app-error-cause.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { APP_ERROR_CODES } from './errors'

export function getSerializableAppError(cause: unknown): Record<string, unknown> | null {
if (!cause || typeof cause !== 'object') return null
if (!cause || typeof cause !== 'object' || !('code' in cause)) return null

const payload = cause as Record<string, unknown>
// tRPC wraps `cause` in an Error and superjson strips custom props off Errors; copy to a plain object.
return typeof payload.code === 'string' ? { ...payload } : null
return Object.values(APP_ERROR_CODES).some((code) => code === cause.code) ? { ...cause } : null
}
7 changes: 4 additions & 3 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,17 +220,18 @@ export class AppError {
}

// System errors
static internalError(message?: string): never {
static internalError(message?: string, cause?: Error): never {
throw new TRPCError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: message ?? 'An internal server error occurred',
cause,
})
}

static databaseError(operation?: string): never {
static databaseError(operation?: string, cause?: Error): never {
const message = operation ? `Database error during ${operation}` : ERROR_MESSAGES.DATABASE_ERROR

throw new TRPCError({ code: ERROR_CODES.INTERNAL_SERVER_ERROR, message })
throw new TRPCError({ code: ERROR_CODES.INTERNAL_SERVER_ERROR, message, cause })
}

// Generic error thrower with custom code and message
Expand Down
Loading
Loading