Error Handling and Observability for Next.js and Supabase Applications
Comprehensive guide to error handling, logging, monitoring, and observability for production Next.js and Supabase applications. Learn error boundaries, structured logging, performance monitoring, and debugging strategies.
Error handling and observability are critical for maintaining reliable, high-performance applications in production. This comprehensive guide teaches you how to implement robust error handling, structured logging, and comprehensive monitoring for Next.js and Supabase applications.
Proper error handling prevents crashes and provides graceful degradation when things go wrong. Observability gives you visibility into your application's behavior, helping you identify issues before they impact users and debug problems quickly when they occur.
This guide covers everything from basic error boundaries to advanced distributed tracing, with production-tested patterns that will help you build resilient, maintainable applications.
Error Handling Fundamentals#
Structured Error Types#
Define consistent error types and handling patterns across your application.
// lib/errors/types.ts
export enum ErrorCode {
// Authentication errors
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
SESSION_EXPIRED = 'SESSION_EXPIRED',
// Validation errors
VALIDATION_ERROR = 'VALIDATION_ERROR',
INVALID_INPUT = 'INVALID_INPUT',
// Database errors
DATABASE_ERROR = 'DATABASE_ERROR',
RECORD_NOT_FOUND = 'RECORD_NOT_FOUND',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Network errors
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
// Business logic errors
BUSINESS_RULE_VIOLATION = 'BUSINESS_RULE_VIOLATION',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
// System errors
INTERNAL_ERROR = 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE'
}
export class AppError extends Error {
public readonly code: ErrorCode
public readonly statusCode: number
public readonly isOperational: boolean
public readonly context?: Record<string, any>
public readonly timestamp: string
public readonly requestId?: string
constructor(
message: string,
code: ErrorCode,
statusCode = 500,
isOperational = true,
context?: Record<string, any>,
requestId?: string
) {
super(message)
this.name = this.constructor.name
this.code = code
this.statusCode = statusCode
this.isOperational = isOperational
this.context = context
this.timestamp = new Date().toISOString()
this.requestId = requestId
Error.captureStackTrace(this, this.constructor)
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
isOperational: this.isOperational,
context: this.context,
timestamp: this.timestamp,
requestId: this.requestId,
stack: this.stack
}
}
}
// Specific error classes
export class ValidationError extends AppError {
constructor(message: string, context?: Record<string, any>, requestId?: string) {
super(message, ErrorCode.VALIDATION_ERROR, 400, true, context, requestId)
}
}
export class AuthenticationError extends AppError {
constructor(message: string, context?: Record<string, any>, requestId?: string) {
super(message, ErrorCode.UNAUTHORIZED, 401, true, context, requestId)
}
}
export class DatabaseError extends AppError {
constructor(message: string, context?: Record<string, any>, requestId?: string) {
super(message, ErrorCode.DATABASE_ERROR, 500, true, context, requestId)
}
}
export class BusinessRuleError extends AppError {
constructor(message: string, context?: Record<string, any>, requestId?: string) {
super(message, ErrorCode.BUSINESS_RULE_VIOLATION, 422, true, context, requestId)
}
}
Supabase Error Handling#
Implement comprehensive error handling for Supabase operations.
// lib/errors/supabase-handler.ts
import { PostgrestError } from '@supabase/supabase-js'
import { AppError, ErrorCode, DatabaseError, AuthenticationError, ValidationError } from './types'
export class SupabaseErrorHandler {
static handleError(error: any, context?: Record<string, any>, requestId?: string): AppError {
// Handle Supabase-specific errors
if (this.isPostgrestError(error)) {
return this.handlePostgrestError(error, context, requestId)
}
// Handle authentication errors
if (this.isAuthError(error)) {
return this.handleAuthError(error, context, requestId)
}
// Handle network errors
if (this.isNetworkError(error)) {
return new AppError(
'Network connection failed',
ErrorCode.NETWORK_ERROR,
503,
true,
{ ...context, originalError: error.message },
requestId
)
}
// Handle unknown errors
return new AppError(
'An unexpected error occurred',
ErrorCode.INTERNAL_ERROR,
500,
false,
{ ...context, originalError: error.message },
requestId
)
}
private static isPostgrestError(error: any): error is PostgrestError {
return error && typeof error.code === 'string' && typeof error.message === 'string'
}
private static isAuthError(error: any): boolean {
return error && (
error.message?.includes('JWT') ||
error.message?.includes('auth') ||
error.message?.includes('session')
)
}
private static isNetworkError(error: any): boolean {
return error && (
error.code === 'NETWORK_ERROR' ||
error.message?.includes('fetch') ||
error.message?.includes('network')
)
}
private static handlePostgrestError(
error: PostgrestError,
context?: Record<string, any>,
requestId?: string
): AppError {
const errorContext = {
...context,
postgrestCode: error.code,
details: error.details,
hint: error.hint
}
// Map PostgreSQL error codes to application errors
switch (error.code) {
case '23505': // unique_violation
return new ValidationError(
'A record with this information already exists',
errorContext,
requestId
)
case '23503': // foreign_key_violation
return new ValidationError(
'Referenced record does not exist',
errorContext,
requestId
)
case '23514': // check_violation
return new ValidationError(
'Data validation failed',
errorContext,
requestId
)
case '42501': // insufficient_privilege
return new AuthenticationError(
'Insufficient permissions to perform this operation',
errorContext,
requestId
)
case 'PGRST116': // No rows found
return new AppError(
'Record not found',
ErrorCode.RECORD_NOT_FOUND,
404,
true,
errorContext,
requestId
)
default:
return new DatabaseError(
error.message || 'Database operation failed',
errorContext,
requestId
)
}
}
private static handleAuthError(
error: any,
context?: Record<string, any>,
requestId?: string
): AppError {
if (error.message?.includes('JWT expired')) {
return new AppError(
'Session expired. Please log in again.',
ErrorCode.SESSION_EXPIRED,
401,
true,
context,
requestId
)
}
return new AuthenticationError(
error.message || 'Authentication failed',
context,
requestId
)
}
}
// Usage in data access layer
export async function safeSupabaseOperation<T>(
operation: () => Promise<{ data: T | null, error: any }>,
context?: Record<string, any>,
requestId?: string
): Promise<T> {
try {
const { data, error } = await operation()
if (error) {
throw SupabaseErrorHandler.handleError(error, context, requestId)
}
if (data === null) {
throw new AppError(
'No data returned from operation',
ErrorCode.RECORD_NOT_FOUND,
404,
true,
context,
requestId
)
}
return data
} catch (error) {
if (error instanceof AppError) {
throw error
}
throw SupabaseErrorHandler.handleError(error, context, requestId)
}
}
Error Boundaries and Recovery#
Implement React error boundaries for graceful error handling in the UI.
// components/ErrorBoundary.tsx
'use client'
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { AppError } from '@/lib/errors/types'
import { Logger } from '@/lib/logging/logger'
interface Props {
children: ReactNode
fallback?: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error?: Error
errorId?: string
}
export class ErrorBoundary extends Component<Props, State> {
private logger = new Logger('ErrorBoundary')
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorId: Math.random().toString(36).substring(2, 15)
}
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const errorId = this.state.errorId!
// Log the error
this.logger.error('React Error Boundary caught an error', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
errorId,
timestamp: new Date().toISOString()
})
// Call custom error handler
this.props.onError?.(error, errorInfo)
// Report to error monitoring service
this.reportError(error, errorInfo, errorId)
}
private async reportError(error: Error, errorInfo: ErrorInfo, errorId: string) {
try {
await fetch('/api/errors/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
errorId,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString()
})
})
} catch (reportingError) {
console.error('Failed to report error:', reportingError)
}
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="error-boundary">
<div className="error-content">
<h2>Something went wrong</h2>
<p>We apologize for the inconvenience. The error has been reported and we're working to fix it.</p>
<details className="error-details">
<summary>Error Details (ID: {this.state.errorId})</summary>
<pre>{this.state.error?.message}</pre>
</details>
<button
onClick={() => window.location.reload()}
className="retry-button"
>
Reload Page
</button>
</div>
</div>
)
}
return this.props.children
}
}
// Global error boundary for the app
// app/global-error.tsx
'use client'
import { ErrorBoundary } from '@/components/ErrorBoundary'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<div className="global-error">
<h1>Application Error</h1>
<p>A critical error occurred. Please try refreshing the page.</p>
{error.digest && (
<p className="error-digest">Error ID: {error.digest}</p>
)}
<button onClick={reset}>Try Again</button>
</div>
</body>
</html>
)
}
// Route-level error handling
// app/posts/error.tsx
'use client'
export default function PostsError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="posts-error">
<h2>Failed to load posts</h2>
<p>There was an error loading the posts. Please try again.</p>
<button onClick={reset}>Retry</button>
</div>
)
}
Structured Logging System#
Logger Implementation#
Implement a comprehensive logging system with structured output and multiple transports.
// lib/logging/logger.ts
export enum LogLevel {
ERROR = 0,
WARN = 1,
INFO = 2,
DEBUG = 3
}
export interface LogEntry {
level: LogLevel
message: string
timestamp: string
context?: Record<string, any>
requestId?: string
userId?: string
organizationId?: string
source: string
environment: string
}
export interface LogTransport {
log(entry: LogEntry): Promise<void>
}
export class Logger {
private transports: LogTransport[] = []
private minLevel: LogLevel
private source: string
constructor(source: string, minLevel = LogLevel.INFO) {
this.source = source
this.minLevel = minLevel
// Add default transports
if (typeof window === 'undefined') {
// Server-side transports
this.transports.push(new ConsoleTransport())
if (process.env.NODE_ENV === 'production') {
this.transports.push(new FileTransport())
this.transports.push(new DatabaseTransport())
}
} else {
// Client-side transports
this.transports.push(new ConsoleTransport())
this.transports.push(new RemoteTransport())
}
}
private async log(level: LogLevel, message: string, context?: Record<string, any>) {
if (level > this.minLevel) return
const entry: LogEntry = {
level,
message,
timestamp: new Date().toISOString(),
context,
requestId: this.getRequestId(),
userId: this.getUserId(),
organizationId: this.getOrganizationId(),
source: this.source,
environment: process.env.NODE_ENV || 'development'
}
// Log to all transports
await Promise.allSettled(
this.transports.map(transport => transport.log(entry))
)
}
error(message: string, context?: Record<string, any>) {
return this.log(LogLevel.ERROR, message, context)
}
warn(message: string, context?: Record<string, any>) {
return this.log(LogLevel.WARN, message, context)
}
info(message: string, context?: Record<string, any>) {
return this.log(LogLevel.INFO, message, context)
}
debug(message: string, context?: Record<string, any>) {
return this.log(LogLevel.DEBUG, message, context)
}
private getRequestId(): string | undefined {
// Get from async local storage or headers
return typeof window === 'undefined'
? process.env.REQUEST_ID
: undefined
}
private getUserId(): string | undefined {
// Get from session or context
return typeof window !== 'undefined'
? localStorage.getItem('userId') || undefined
: undefined
}
private getOrganizationId(): string | undefined {
// Get from session or context
return typeof window !== 'undefined'
? localStorage.getItem('organizationId') || undefined
: undefined
}
}
// Console transport
class ConsoleTransport implements LogTransport {
async log(entry: LogEntry): Promise<void> {
const levelNames = ['ERROR', 'WARN', 'INFO', 'DEBUG']
const levelName = levelNames[entry.level]
const logMessage = `[${entry.timestamp}] ${levelName} [${entry.source}] ${entry.message}`
switch (entry.level) {
case LogLevel.ERROR:
console.error(logMessage, entry.context)
break
case LogLevel.WARN:
console.warn(logMessage, entry.context)
break
case LogLevel.INFO:
console.info(logMessage, entry.context)
break
case LogLevel.DEBUG:
console.debug(logMessage, entry.context)
break
}
}
}
// File transport (server-side)
class FileTransport implements LogTransport {
async log(entry: LogEntry): Promise<void> {
if (typeof window !== 'undefined') return
const fs = await import('fs/promises')
const path = await import('path')
const logDir = path.join(process.cwd(), 'logs')
const logFile = path.join(logDir, `app-${new Date().toISOString().split('T')[0]}.log`)
try {
await fs.mkdir(logDir, { recursive: true })
await fs.appendFile(logFile, JSON.stringify(entry) + '\n')
} catch (error) {
console.error('Failed to write to log file:', error)
}
}
}
// Database transport (server-side)
class DatabaseTransport implements LogTransport {
async log(entry: LogEntry): Promise<void> {
if (typeof window !== 'undefined') return
if (entry.level > LogLevel.WARN) return // Only log warnings and errors to DB
try {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
await supabase.from('application_logs').insert({
level: entry.level,
message: entry.message,
context: entry.context,
request_id: entry.requestId,
user_id: entry.userId,
organization_id: entry.organizationId,
source: entry.source,
environment: entry.environment,
timestamp: entry.timestamp
})
} catch (error) {
console.error('Failed to write to database log:', error)
}
}
}
// Remote transport (client-side)
class RemoteTransport implements LogTransport {
private buffer: LogEntry[] = []
private flushInterval: NodeJS.Timeout
constructor() {
// Flush logs every 5 seconds
this.flushInterval = setInterval(() => this.flush(), 5000)
// Flush on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => this.flush())
}
}
async log(entry: LogEntry): Promise<void> {
this.buffer.push(entry)
// Flush immediately for errors
if (entry.level === LogLevel.ERROR) {
await this.flush()
}
}
private async flush(): Promise<void> {
if (this.buffer.length === 0) return
const logs = [...this.buffer]
this.buffer = []
try {
await fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ logs })
})
} catch (error) {
console.error('Failed to send logs to server:', error)
// Put logs back in buffer for retry
this.buffer.unshift(...logs)
}
}
}
Request Correlation and Tracing#
Implement request correlation for tracking requests across services.
// lib/tracing/correlation.ts
import { AsyncLocalStorage } from 'async_hooks'
interface RequestContext {
requestId: string
userId?: string
organizationId?: string
startTime: number
path: string
method: string
}
export class CorrelationManager {
private static storage = new AsyncLocalStorage<RequestContext>()
static run<T>(context: RequestContext, callback: () => T): T {
return this.storage.run(context, callback)
}
static getContext(): RequestContext | undefined {
return this.storage.getStore()
}
static getRequestId(): string | undefined {
return this.getContext()?.requestId
}
static getUserId(): string | undefined {
return this.getContext()?.userId
}
static getOrganizationId(): string | undefined {
return this.getContext()?.organizationId
}
}
// Middleware for Next.js API routes
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { CorrelationManager } from '@/lib/tracing/correlation'
export function middleware(request: NextRequest) {
const requestId = crypto.randomUUID()
const startTime = Date.now()
// Add request ID to headers
const response = NextResponse.next()
response.headers.set('X-Request-ID', requestId)
// Set up correlation context
const context = {
requestId,
startTime,
path: request.nextUrl.pathname,
method: request.method
}
return CorrelationManager.run(context, () => response)
}
export const config = {
matcher: [
'/api/:path*',
'/((?!_next/static|_next/image|favicon.ico).*)'
]
}
This comprehensive error handling and observability guide provides the foundation for building resilient, maintainable applications. Proper error handling and monitoring are essential for production applications, helping you identify issues quickly and provide excellent user experiences even when things go wrong.
Performance Monitoring#
Core Web Vitals Tracking#
Monitor and optimize Core Web Vitals for better user experience and SEO.
// lib/monitoring/web-vitals.ts
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
export interface WebVitalMetric {
name: string
value: number
rating: 'good' | 'needs-improvement' | 'poor'
delta: number
id: string
navigationType: string
}
export class WebVitalsMonitor {
private metrics: WebVitalMetric[] = []
private reportingEndpoint = '/api/analytics/web-vitals'
constructor() {
if (typeof window !== 'undefined') {
this.initializeTracking()
}
}
private initializeTracking() {
// Track all Core Web Vitals
getCLS(this.handleMetric.bind(this))
getFID(this.handleMetric.bind(this))
getFCP(this.handleMetric.bind(this))
getLCP(this.handleMetric.bind(this))
getTTFB(this.handleMetric.bind(this))
}
private handleMetric(metric: any) {
const webVitalMetric: WebVitalMetric = {
name: metric.name,
value: metric.value,
rating: this.getRating(metric.name, metric.value),
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType || 'navigate'
}
this.metrics.push(webVitalMetric)
this.reportMetric(webVitalMetric)
}
private getRating(name: string, value: number): 'good' | 'needs-improvement' | 'poor' {
const thresholds = {
CLS: { good: 0.1, poor: 0.25 },
FID: { good: 100, poor: 300 },
FCP: { good: 1800, poor: 3000 },
LCP: { good: 2500, poor: 4000 },
TTFB: { good: 800, poor: 1800 }
}
const threshold = thresholds[name as keyof typeof thresholds]
if (!threshold) return 'good'
if (value <= threshold.good) return 'good'
if (value <= threshold.poor) return 'needs-improvement'
return 'poor'
}
private async reportMetric(metric: WebVitalMetric) {
try {
await fetch(this.reportingEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...metric,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
})
})
} catch (error) {
console.error('Failed to report web vital:', error)
}
}
getMetrics(): WebVitalMetric[] {
return [...this.metrics]
}
getMetricsByRating(rating: 'good' | 'needs-improvement' | 'poor'): WebVitalMetric[] {
return this.metrics.filter(metric => metric.rating === rating)
}
}
// Initialize global web vitals monitoring
export const webVitalsMonitor = new WebVitalsMonitor()
// React hook for component-level monitoring
export function useWebVitals() {
return {
metrics: webVitalsMonitor.getMetrics(),
goodMetrics: webVitalsMonitor.getMetricsByRating('good'),
poorMetrics: webVitalsMonitor.getMetricsByRating('poor')
}
}
API Performance Monitoring#
Monitor API route performance and database query times.
// lib/monitoring/api-performance.ts
export class APIPerformanceMonitor {
private static instance: APIPerformanceMonitor
private metrics: Map<string, PerformanceMetric[]> = new Map()
static getInstance(): APIPerformanceMonitor {
if (!this.instance) {
this.instance = new APIPerformanceMonitor()
}
return this.instance
}
async trackAPICall<T>(
operation: string,
fn: () => Promise<T>,
context?: Record<string, any>
): Promise<T> {
const startTime = Date.now()
const requestId = context?.requestId || crypto.randomUUID()
try {
const result = await fn()
const duration = Date.now() - startTime
await this.recordMetric({
operation,
duration,
status: 'success',
requestId,
timestamp: new Date().toISOString(),
context
})
return result
} catch (error) {
const duration = Date.now() - startTime
await this.recordMetric({
operation,
duration,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
timestamp: new Date().toISOString(),
context
})
throw error
}
}
private async recordMetric(metric: PerformanceMetric) {
// Store in memory
const existing = this.metrics.get(metric.operation) || []
existing.push(metric)
this.metrics.set(metric.operation, existing.slice(-100)) // Keep last 100
// Send to monitoring service
await this.sendToMonitoring(metric)
}
private async sendToMonitoring(metric: PerformanceMetric) {
try {
if (typeof window === 'undefined') {
// Server-side: store in database
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
await supabase.from('api_metrics').insert({
operation: metric.operation,
duration: metric.duration,
status: metric.status,
error: metric.error,
request_id: metric.requestId,
context: metric.context,
timestamp: metric.timestamp
})
} else {
// Client-side: send to API
await fetch('/api/monitoring/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metric)
})
}
} catch (error) {
console.error('Failed to send metric to monitoring:', error)
}
}
getMetrics(operation?: string): PerformanceMetric[] {
if (operation) {
return this.metrics.get(operation) || []
}
return Array.from(this.metrics.values()).flat()
}
getAverageResponseTime(operation: string, timeWindow = 300000): number {
const cutoff = Date.now() - timeWindow
const metrics = this.getMetrics(operation)
.filter(m => new Date(m.timestamp).getTime() > cutoff)
.filter(m => m.status === 'success')
if (metrics.length === 0) return 0
const total = metrics.reduce((sum, m) => sum + m.duration, 0)
return total / metrics.length
}
getErrorRate(operation: string, timeWindow = 300000): number {
const cutoff = Date.now() - timeWindow
const metrics = this.getMetrics(operation)
.filter(m => new Date(m.timestamp).getTime() > cutoff)
if (metrics.length === 0) return 0
const errors = metrics.filter(m => m.status === 'error').length
return errors / metrics.length
}
}
interface PerformanceMetric {
operation: string
duration: number
status: 'success' | 'error'
error?: string
requestId: string
timestamp: string
context?: Record<string, any>
}
// Decorator for automatic API monitoring
export function MonitorPerformance(operation?: string) {
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value
const operationName = operation || `${target.constructor.name}.${propertyName}`
descriptor.value = async function (...args: any[]) {
const monitor = APIPerformanceMonitor.getInstance()
return monitor.trackAPICall(operationName, () => method.apply(this, args))
}
return descriptor
}
}
// Usage in API routes
// app/api/posts/route.ts
import { APIPerformanceMonitor } from '@/lib/monitoring/api-performance'
export async function GET(request: Request) {
const monitor = APIPerformanceMonitor.getInstance()
return monitor.trackAPICall('posts.list', async () => {
const supabase = createClient()
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('status', 'published')
.order('created_at', { ascending: false })
if (error) throw error
return Response.json(data)
}, {
requestId: request.headers.get('x-request-id') || undefined
})
}
Database Query Monitoring#
Monitor Supabase query performance and identify slow queries.
// lib/monitoring/database-monitor.ts
export class DatabaseMonitor {
private queryMetrics: Map<string, QueryMetric[]> = new Map()
async trackQuery<T>(
queryName: string,
query: () => Promise<T>,
context?: Record<string, any>
): Promise<T> {
const startTime = performance.now()
const requestId = context?.requestId || crypto.randomUUID()
try {
const result = await query()
const duration = performance.now() - startTime
await this.recordQueryMetric({
queryName,
duration,
status: 'success',
requestId,
timestamp: new Date().toISOString(),
context
})
// Log slow queries
if (duration > 1000) { // Queries taking more than 1 second
console.warn(`Slow query detected: ${queryName} took ${duration.toFixed(2)}ms`, {
context,
requestId
})
}
return result
} catch (error) {
const duration = performance.now() - startTime
await this.recordQueryMetric({
queryName,
duration,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
timestamp: new Date().toISOString(),
context
})
throw error
}
}
private async recordQueryMetric(metric: QueryMetric) {
// Store in memory for immediate access
const existing = this.queryMetrics.get(metric.queryName) || []
existing.push(metric)
this.queryMetrics.set(metric.queryName, existing.slice(-50)) // Keep last 50
// Store in database for long-term analysis
if (typeof window === 'undefined') {
try {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
await supabase.from('query_metrics').insert({
query_name: metric.queryName,
duration: metric.duration,
status: metric.status,
error: metric.error,
request_id: metric.requestId,
context: metric.context,
timestamp: metric.timestamp
})
} catch (error) {
console.error('Failed to store query metric:', error)
}
}
}
getSlowQueries(thresholdMs = 1000): QueryMetric[] {
return Array.from(this.queryMetrics.values())
.flat()
.filter(metric => metric.duration > thresholdMs)
.sort((a, b) => b.duration - a.duration)
}
getQueryStats(queryName: string): {
averageDuration: number
errorRate: number
totalExecutions: number
} {
const metrics = this.queryMetrics.get(queryName) || []
if (metrics.length === 0) {
return { averageDuration: 0, errorRate: 0, totalExecutions: 0 }
}
const successfulQueries = metrics.filter(m => m.status === 'success')
const averageDuration = successfulQueries.length > 0
? successfulQueries.reduce((sum, m) => sum + m.duration, 0) / successfulQueries.length
: 0
const errorRate = metrics.filter(m => m.status === 'error').length / metrics.length
return {
averageDuration,
errorRate,
totalExecutions: metrics.length
}
}
}
interface QueryMetric {
queryName: string
duration: number
status: 'success' | 'error'
error?: string
requestId: string
timestamp: string
context?: Record<string, any>
}
// Enhanced Supabase client with monitoring
export function createMonitoredSupabaseClient() {
const supabase = createClient()
const monitor = new DatabaseMonitor()
return {
...supabase,
from: (table: string) => {
const queryBuilder = supabase.from(table)
// Wrap common methods with monitoring
return {
...queryBuilder,
select: (columns?: string) => {
const builder = queryBuilder.select(columns)
return {
...builder,
execute: () => monitor.trackQuery(
`${table}.select`,
() => builder
)
}
},
insert: (values: any) => {
const builder = queryBuilder.insert(values)
return {
...builder,
execute: () => monitor.trackQuery(
`${table}.insert`,
() => builder
)
}
},
update: (values: any) => {
const builder = queryBuilder.update(values)
return {
...builder,
execute: () => monitor.trackQuery(
`${table}.update`,
() => builder
)
}
},
delete: () => {
const builder = queryBuilder.delete()
return {
...builder,
execute: () => monitor.trackQuery(
`${table}.delete`,
() => builder
)
}
}
}
}
}
}
export const databaseMonitor = new DatabaseMonitor()
Alerting and Incident Response#
Alert Configuration#
Set up intelligent alerting for critical issues and performance degradation.
// lib/alerting/alert-manager.ts
export enum AlertSeverity {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical'
}
export interface AlertRule {
id: string
name: string
condition: AlertCondition
severity: AlertSeverity
channels: AlertChannel[]
cooldownMinutes: number
enabled: boolean
}
export interface AlertCondition {
metric: string
operator: 'gt' | 'lt' | 'eq' | 'gte' | 'lte'
threshold: number
timeWindow: number // minutes
evaluationInterval: number // minutes
}
export interface AlertChannel {
type: 'email' | 'slack' | 'webhook'
config: Record<string, any>
}
export class AlertManager {
private rules: Map<string, AlertRule> = new Map()
private lastAlerts: Map<string, number> = new Map()
constructor() {
this.loadDefaultRules()
this.startEvaluationLoop()
}
private loadDefaultRules() {
const defaultRules: AlertRule[] = [
{
id: 'high-error-rate',
name: 'High Error Rate',
condition: {
metric: 'error_rate',
operator: 'gt',
threshold: 0.05, // 5%
timeWindow: 5,
evaluationInterval: 1
},
severity: AlertSeverity.HIGH,
channels: [
{ type: 'email', config: { recipients: ['admin@example.com'] } },
{ type: 'slack', config: { webhook: process.env.SLACK_WEBHOOK_URL } }
],
cooldownMinutes: 15,
enabled: true
},
{
id: 'slow-response-time',
name: 'Slow Response Time',
condition: {
metric: 'avg_response_time',
operator: 'gt',
threshold: 2000, // 2 seconds
timeWindow: 10,
evaluationInterval: 2
},
severity: AlertSeverity.MEDIUM,
channels: [
{ type: 'slack', config: { webhook: process.env.SLACK_WEBHOOK_URL } }
],
cooldownMinutes: 30,
enabled: true
},
{
id: 'database-connection-errors',
name: 'Database Connection Errors',
condition: {
metric: 'db_connection_errors',
operator: 'gt',
threshold: 10,
timeWindow: 5,
evaluationInterval: 1
},
severity: AlertSeverity.CRITICAL,
channels: [
{ type: 'email', config: { recipients: ['admin@example.com'] } },
{ type: 'slack', config: { webhook: process.env.SLACK_WEBHOOK_URL } }
],
cooldownMinutes: 5,
enabled: true
}
]
defaultRules.forEach(rule => this.rules.set(rule.id, rule))
}
private startEvaluationLoop() {
setInterval(() => {
this.evaluateRules()
}, 60000) // Evaluate every minute
}
private async evaluateRules() {
for (const rule of this.rules.values()) {
if (!rule.enabled) continue
try {
const shouldAlert = await this.evaluateRule(rule)
if (shouldAlert && this.canSendAlert(rule.id, rule.cooldownMinutes)) {
await this.sendAlert(rule)
this.lastAlerts.set(rule.id, Date.now())
}
} catch (error) {
console.error(`Failed to evaluate rule ${rule.id}:`, error)
}
}
}
private async evaluateRule(rule: AlertRule): Promise<boolean> {
const metric = await this.getMetricValue(rule.condition)
switch (rule.condition.operator) {
case 'gt': return metric > rule.condition.threshold
case 'lt': return metric < rule.condition.threshold
case 'eq': return metric === rule.condition.threshold
case 'gte': return metric >= rule.condition.threshold
case 'lte': return metric <= rule.condition.threshold
default: return false
}
}
private async getMetricValue(condition: AlertCondition): Promise<number> {
const endTime = Date.now()
const startTime = endTime - (condition.timeWindow * 60 * 1000)
// This would integrate with your metrics storage system
switch (condition.metric) {
case 'error_rate':
return this.calculateErrorRate(startTime, endTime)
case 'avg_response_time':
return this.calculateAverageResponseTime(startTime, endTime)
case 'db_connection_errors':
return this.countDatabaseErrors(startTime, endTime)
default:
return 0
}
}
private async calculateErrorRate(startTime: number, endTime: number): Promise<number> {
// Query your metrics database
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
const { data } = await supabase
.rpc('calculate_error_rate', {
start_time: new Date(startTime).toISOString(),
end_time: new Date(endTime).toISOString()
})
return data?.error_rate || 0
}
private async calculateAverageResponseTime(startTime: number, endTime: number): Promise<number> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
const { data } = await supabase
.rpc('calculate_avg_response_time', {
start_time: new Date(startTime).toISOString(),
end_time: new Date(endTime).toISOString()
})
return data?.avg_response_time || 0
}
private async countDatabaseErrors(startTime: number, endTime: number): Promise<number> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = createClient()
const { count } = await supabase
.from('application_logs')
.select('*', { count: 'exact', head: true })
.eq('level', 0) // ERROR level
.ilike('message', '%database%')
.gte('timestamp', new Date(startTime).toISOString())
.lte('timestamp', new Date(endTime).toISOString())
return count || 0
}
private canSendAlert(ruleId: string, cooldownMinutes: number): boolean {
const lastAlert = this.lastAlerts.get(ruleId)
if (!lastAlert) return true
const cooldownMs = cooldownMinutes * 60 * 1000
return Date.now() - lastAlert > cooldownMs
}
private async sendAlert(rule: AlertRule) {
const alertData = {
rule: rule.name,
severity: rule.severity,
timestamp: new Date().toISOString(),
condition: rule.condition
}
for (const channel of rule.channels) {
try {
await this.sendToChannel(channel, alertData)
} catch (error) {
console.error(`Failed to send alert to ${channel.type}:`, error)
}
}
}
private async sendToChannel(channel: AlertChannel, alertData: any) {
switch (channel.type) {
case 'email':
await this.sendEmailAlert(channel.config, alertData)
break
case 'slack':
await this.sendSlackAlert(channel.config, alertData)
break
case 'webhook':
await this.sendWebhookAlert(channel.config, alertData)
break
}
}
private async sendEmailAlert(config: any, alertData: any) {
// Implement email sending logic
console.log('Sending email alert:', alertData)
}
private async sendSlackAlert(config: any, alertData: any) {
if (!config.webhook) return
const message = {
text: `🚨 Alert: ${alertData.rule}`,
attachments: [
{
color: this.getSeverityColor(alertData.severity),
fields: [
{ title: 'Severity', value: alertData.severity, short: true },
{ title: 'Time', value: alertData.timestamp, short: true },
{ title: 'Condition', value: JSON.stringify(alertData.condition), short: false }
]
}
]
}
await fetch(config.webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
}
private async sendWebhookAlert(config: any, alertData: any) {
await fetch(config.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(alertData)
})
}
private getSeverityColor(severity: AlertSeverity): string {
switch (severity) {
case AlertSeverity.LOW: return 'good'
case AlertSeverity.MEDIUM: return 'warning'
case AlertSeverity.HIGH: return 'danger'
case AlertSeverity.CRITICAL: return '#ff0000'
default: return 'good'
}
}
}
export const alertManager = new AlertManager()
This comprehensive error handling and observability guide provides the foundation for building production-ready applications with robust monitoring, alerting, and debugging capabilities. These patterns will help you identify and resolve issues quickly, maintain high availability, and provide excellent user experiences even when problems occur.
Remember that observability is not just about collecting data—it's about having the right information at the right time to make informed decisions about your application's health and performance. Start with basic error handling and logging, then gradually add more sophisticated monitoring and alerting as your application grows.
Frequently Asked Questions
Related Guides
AI Integration for Next.js + Supabase Applications
Complete guide to integrating AI capabilities into Next.js and Supabase applications. Learn OpenAI integration, chat interfaces, vector search, RAG systems,...
Complete Guide to Building SaaS with Next.js and Supabase
Master full-stack SaaS development with Next.js 15 and Supabase. From database design to deployment, learn everything you need to build production-ready...
Deploying Next.js + Supabase to Production
Complete guide to deploying Next.js and Supabase applications to production. Learn Vercel deployment, environment configuration, database migrations, CI/CD...