From 5ef09c3d428d379e358a1c50c382e5de03e08c4c Mon Sep 17 00:00:00 2001 From: JACOB STANLEY Date: Sat, 25 Jul 2026 23:13:37 +0100 Subject: [PATCH] https://github.com/Menjay7/teachLink_web.git --- .env.example | 19 ++ docs/monitoring.md | 249 ++++++++++++++++- next.config.ts | 3 + pages/system-health.js | 19 +- src/app/api/approvals/route.ts | 10 +- src/app/api/auth/github/callback/route.ts | 4 +- src/app/api/auth/google/callback/route.ts | 4 +- src/app/api/auth/login/route.ts | 2 - src/app/api/performance/zoom-metrics/route.ts | 4 +- src/app/api/polls/route.ts | 7 +- .../components/dashboard/DashboardGrid.tsx | 4 +- src/app/error.tsx | 20 +- src/app/global-error.tsx | 15 +- src/app/hooks/useDashboardWidgets.tsx | 5 +- src/hooks/useVideoPlayerLazy.ts | 4 +- src/instrumentation.ts | 57 ++++ src/lib/auth/jwt.ts | 4 +- src/lib/db/migrate.ts | 4 +- src/lib/errors/__tests__/index.test.ts | 264 ++++++++++++++++++ src/lib/errors/index.ts | 67 ++++- src/services/errorReporting.ts | 49 +++- 21 files changed, 762 insertions(+), 52 deletions(-) create mode 100644 src/instrumentation.ts create mode 100644 src/lib/errors/__tests__/index.test.ts diff --git a/.env.example b/.env.example index 8a9eb4ad..a98b4357 100644 --- a/.env.example +++ b/.env.example @@ -101,3 +101,22 @@ GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret GITHUB_REDIRECT_URI=http://localhost:3000/api/auth/github/callback + +# ───────────────────────────────────────────────────────────────────────────── +# Error Monitoring & Structured Error Tracking (#327) +# ───────────────────────────────────────────────────────────────────────────── +# Set NEXT_PUBLIC_SENTRY_DSN to enable remote error reporting to Sentry (or any +# Sentry-compatible backend). When set, the error tracking system will send +# structured error reports to the configured endpoint. +# +# When unset, errors are still captured locally via the structured logger +# (Pino) and stored in the in-memory transport for diagnostics. +# +# NEXT_PUBLIC_SENTRY_DSN is exposed to the client bundle. For server-only +# deployments, use SENTRY_DSN instead (not exposed to the browser). +# +# Example: +# NEXT_PUBLIC_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 +# +# NEXT_PUBLIC_SENTRY_DSN= +# SENTRY_DSN= diff --git a/docs/monitoring.md b/docs/monitoring.md index 935f41d3..625df724 100644 --- a/docs/monitoring.md +++ b/docs/monitoring.md @@ -1,13 +1,22 @@ -# Push Notifications Monitoring Dashboard +# Monitoring & Error Tracking -## Quick Start +This document covers the two monitoring systems in TeachLink: + +1. **System Health Dashboard** — real-time metrics for push notifications +2. **Error Tracking** — structured error capture, breadcrumbs, and reporting + +--- + +## 1. System Health Dashboard + +### Quick Start 1. Run `npm install` then `npm run dev` 2. Open `http://localhost:3000/system-health` 3. Click "Enable Notifications" and allow permission 4. Send a test message -## Features +### Features - **System Health**: Real memory, CPU, uptime from Node.js - **Notification Metrics**: Sent, Delivered, Clicked, Failed counts @@ -16,8 +25,240 @@ - **Dark Mode**: Toggle light/dark themes - **Auto-Refresh**: Updates every 5 seconds -## API Endpoints +### API Endpoints - `GET /api/notifications/metrics` - System metrics - `GET/POST/DELETE /api/notifications/track` - Event tracking - `POST /api/notifications/send-notification` - Send test notifications + +--- + +## 2. Error Tracking & Structured Logging + +### Overview + +TeachLink uses a three-layer error tracking system: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Public API (src/lib/errors/index.ts) │ +│ Sentry-compatible: init, captureException, │ +│ captureMessage, addBreadcrumb, setUser, getBreadcrumbs │ +├─────────────────────────────────────────────────────────┤ +│ ErrorReportingService (src/services/errorReporting.ts) │ +│ Singleton: breadcrumbs, session IDs, user identity, │ +│ dispatches to structured logger + /api/errors/report │ +├─────────────────────────────────────────────────────────┤ +│ Structured Logging (src/lib/logging) │ +│ Pino-based: PII redaction, correlation IDs, │ +│ multi-transport (in-memory, HTTP) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Architecture + +1. **Public API** (`src/lib/errors/index.ts`) — Application code calls + `captureException()`, `captureMessage()`, `addBreadcrumb()`, `setUser()`, + and `getBreadcrumbs()`. These mirror the Sentry SDK interface for easy + future migration to `@sentry/nextjs`. + +2. **ErrorReportingService** (`src/services/errorReporting.ts`) — A singleton + that manages breadcrumbs (max 50), session IDs, user identity, and + dispatches error reports to the structured logging system. In production, + reports are also sent to `/api/errors/report` for server-side persistence + with PII redaction and rate limiting. + +3. **Structured Logging** (`src/lib/logging`) — A Pino-based logger with + automatic PII redaction (passwords, tokens, emails, etc.), correlation ID + propagation via `AsyncLocalStorage`, and multi-transport support + (in-memory buffer for diagnostics, optional HTTP transport for log + aggregation). + +### Configuration + +Set the following environment variables in `.env.local` or your deployment +platform: + +| Variable | Description | Default | +| ----------------------- | -------------------------------------------------------- | ----------- | +| `NEXT_PUBLIC_SENTRY_DSN`| Sentry DSN (client + server). Enables remote reporting. | `undefined` | +| `SENTRY_DSN` | Sentry DSN (server-only). Not exposed to the browser. | `undefined` | +| `LOG_LEVEL` | Minimum log level to emit. | `info` | +| `NEXT_PUBLIC_LOG_LEVEL` | Client-side fallback log level. | `info` | +| `LOG_AGGREGATION_URL` | Optional HTTP endpoint for log aggregation. | `undefined` | + +When no DSN is configured, errors are still captured locally via the +structured logger and stored in the in-memory transport for diagnostics. + +### Initialization + +Error tracking is initialized automatically via Next.js instrumentation: + +- **File**: `src/instrumentation.ts` +- **Config**: `next.config.ts` → `experimental.instrumentationHook: true` + +The `register()` function runs once on the server at startup. It reads the +Sentry DSN from the environment and calls `init()` from `@/lib/errors`, which: + +- Configures the `ErrorReportingService` with the DSN +- Sets up global `unhandledrejection` and `error` event listeners +- Logs a startup event to the structured logger + +The `onError()` hook captures errors during server component rendering. + +### Usage + +#### Capturing exceptions + +```typescript +import { captureException } from '@/lib/errors'; + +try { + // risky operation +} catch (error) { + captureException(error, { + userId: 'usr_123', + tags: { feature: 'billing' }, + extra: { orderId: 'ord_456' }, + }); +} +``` + +#### Capturing messages + +```typescript +import { captureMessage } from '@/lib/errors'; + +captureMessage('Payment retry threshold reached', { + tags: { feature: 'payments' }, +}); +``` + +#### Adding breadcrumbs + +```typescript +import { addBreadcrumb } from '@/lib/errors'; + +addBreadcrumb({ + category: 'navigation', + message: 'User navigated to checkout', + data: { page: '/checkout' }, + level: 'info', +}); +``` + +#### Setting user identity + +```typescript +import { setUser } from '@/lib/errors'; + +// Attach user to subsequent error reports +setUser({ id: 'usr_123', email: 'user@example.com' }); + +// Clear user identity (e.g. on logout) +setUser(null); +``` + +#### Using the structured logger directly + +```typescript +import { createLogger } from '@/lib/logging'; + +const logger = createLogger('courses.enrollment'); + +logger.info('Enrollment process initiated', { + context: { userId: 'usr_123', courseId: 'crs_abc' }, +}); + +logger.error('Enrollment failed', { + context: { userId: 'usr_123', courseId: 'crs_abc' }, + error, +}); +``` + +### Server-Side Error Reporting Endpoint + +**Endpoint**: `POST /api/errors/report` + +This endpoint receives client-side error reports from the +`ErrorReportingService.sendToServer()` method. It: + +- Applies rate limiting (10 requests/minute per IP via the `REPORTING` tier) +- Scrubs PII fields (email, password, token, card, ssn, phone, etc.) before + logging +- Logs the redacted report via the structured logger +- Returns `{ ok: true }` on success + +### Error Boundaries + +Error boundaries are wired to the error tracking system: + +- **`src/app/error.tsx`** — Page-level error boundary. Uses + `captureException()` and `addBreadcrumb()` from `@/lib/errors`. +- **`src/app/global-error.tsx`** — Root-level error boundary. Captures + critical errors with digest information. +- **`src/components/errors/ErrorBoundarySystem.tsx`** — Reusable error + boundary component for client-side React trees. + +### Breadcrumbs + +Breadcrumbs provide context for errors by recording user actions, navigation +events, and system events leading up to an error. The system maintains up to +50 breadcrumbs per session. + +Breadcrumb categories used in the codebase: + +| Category | Source | +| ---------------------- | ------------------------------- | +| `exception` | `captureException()` calls | +| `message` | `captureMessage()` calls | +| `user` | `setUser()` calls | +| `error.tsx` | Page error boundary | +| `global-error` | Root error boundary | +| `errorBoundary` | `ErrorBoundarySystem` component | +| `globalError` | Global JS error handler | +| `unhandledRejection` | Unhandled promise rejections | +| `userAction` | `reportUserAction()` calls | +| `metric` | `reportMetric()` calls | + +### Testing + +Tests for the error tracking system are in: + +- `src/lib/errors/__tests__/index.test.ts` — Public API tests +- `src/app/api/errors/report/__tests__/route.test.ts` — API endpoint tests + +Run tests with: + +```bash +pnpm test -- src/lib/errors +pnpm test -- src/app/api/errors +``` + +### Migration to Sentry + +The public API in `src/lib/errors/index.ts` is designed to be a drop-in +replacement for the Sentry SDK. To migrate to `@sentry/nextjs`: + +1. Install the SDK: `pnpm add @sentry/nextjs` +2. Replace the function bodies in `src/lib/errors/index.ts` with the + corresponding Sentry SDK calls (`Sentry.init()`, `Sentry.captureException()`, + etc.) +3. Remove the `ErrorReportingService` delegation layer +4. The `init()` function already accepts a DSN, which is what + `Sentry.init()` expects + +### PII Redaction Policy + +The logging system automatically redacts sensitive fields from all log +records and error reports: + +- `password`, `pwd`, `credentials` +- `secret`, `token`, `key` +- `authorization`, `auth`, `bearer` +- `email`, `phone`, `ssn`, `creditcard`, `card`, `cvv` + +Bearer tokens inside string messages are also scanned and masked: + +- Input: `"Received token: Bearer eyJhbGciOiJ..."` +- Output: `"Received token: Bearer [REDACTED]"` diff --git a/next.config.ts b/next.config.ts index a068cb22..2bbaf007 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,9 @@ const nextConfig: NextConfig = { // Code-splitting optimization for heavy libraries experimental: { optimizePackageImports: ['@monaco-editor/react', 'video.js', 'ethers'], + // Enable Next.js instrumentation so src/instrumentation.ts is loaded + // at startup for error tracking initialisation (#327). + instrumentationHook: true, }, modularizeImports: { diff --git a/pages/system-health.js b/pages/system-health.js index 5f6919bc..eae0cf98 100644 --- a/pages/system-health.js +++ b/pages/system-health.js @@ -1,4 +1,8 @@ import { useEffect, useState } from 'react'; +import { captureException } from '@/lib/errors'; +import { createLogger } from '@/lib/logging'; + +const logger = createLogger('pages.system-health'); export default function SystemHealthDashboard() { const [isClient, setIsClient] = useState(false); @@ -45,7 +49,10 @@ export default function SystemHealthDashboard() { }); setLastUpdate(new Date()); } catch (error) { - console.error('Fetch error:', error); + logger.error('Fetch error', { error }); + captureException(error, { + extra: { source: 'fetchAllData' }, + }); } }; @@ -83,7 +90,10 @@ export default function SystemHealthDashboard() { alert('Successfully subscribed to push notifications!'); } } catch (error) { - console.error('Subscription error:', error); + logger.error('Subscription error', { error }); + captureException(error, { + extra: { source: 'enableNotifications' }, + }); alert('Failed to subscribe: ' + error.message); } }; @@ -114,7 +124,10 @@ export default function SystemHealthDashboard() { alert(' Failed to send notification'); } } catch (err) { - console.error('Send error:', err); + logger.error('Send error', { error: err }); + captureException(err, { + extra: { source: 'sendNotification' }, + }); alert('Failed to send: ' + err.message); } setLoading(false); diff --git a/src/app/api/approvals/route.ts b/src/app/api/approvals/route.ts index 16ccaeca..1693fdf4 100644 --- a/src/app/api/approvals/route.ts +++ b/src/app/api/approvals/route.ts @@ -6,6 +6,7 @@ import { validateBody, validateQuery } from '@/lib/validation'; import { ApprovalStatus } from '@/types/approvals'; import { query } from '@/lib/db/pool'; import type { ApprovalItem, ReviewDecision } from '@/types/api'; +import { createLogger } from '@/lib/logging'; export const runtime = 'nodejs'; @@ -66,7 +67,8 @@ export async function GET(request: Request): Promise { return addHeaders(NextResponse.json({ success: true, data: dbResult.rows })); } catch (error) { - console.error('[approvals] GET error:', error); + const logger = createLogger('api.approvals'); + logger.error('GET request failed', { error, method: 'GET' }); return addHeaders( NextResponse.json({ success: false, message: 'Database error' }, { status: 500 }), ); @@ -108,7 +110,8 @@ export async function POST(request: Request): Promise { return response; } catch (error) { - console.error('[approvals] POST error:', error); + const logger = createLogger('api.approvals'); + logger.error('POST request failed', { error, method: 'POST' }); return addHeaders( NextResponse.json({ success: false, message: 'Database error' }, { status: 500 }), ); @@ -167,7 +170,8 @@ export async function PATCH(request: Request): Promise { return response; } catch (error) { - console.error('[approvals] PATCH error:', error); + const logger = createLogger('api.approvals'); + logger.error('PATCH request failed', { error, method: 'PATCH' }); return addHeaders( NextResponse.json({ success: false, message: 'Database error' }, { status: 500 }), ); diff --git a/src/app/api/auth/github/callback/route.ts b/src/app/api/auth/github/callback/route.ts index 77fcf01e..69ba3d8b 100644 --- a/src/app/api/auth/github/callback/route.ts +++ b/src/app/api/auth/github/callback/route.ts @@ -3,6 +3,7 @@ import { withRateLimit } from '@/lib/ratelimit'; import { exchangeCodeForToken, getGitHubUser, getGitHubAvatarUrl } from '@/lib/github/oauth'; import type { AuthResponseDTO, AuthErrorDTO } from '@/types/api/auth.dto'; import { edgeLog } from '@/../infra/edge-config'; +import { createLogger } from '@/lib/logging'; export const runtime = 'edge'; @@ -88,8 +89,9 @@ export async function GET( return addHeaders(response) as NextResponse; } catch (error) { + const logger = createLogger('api.auth.github-callback'); edgeLog('error', '/api/auth/github/callback', `Error: ${error}`); - console.error('GitHub OAuth callback error:', error); + logger.error('GitHub OAuth callback error', { error }); return addHeaders( NextResponse.json({ message: 'Internal server error' }, { status: 500 }), diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts index dd3f4c40..611121f9 100644 --- a/src/app/api/auth/google/callback/route.ts +++ b/src/app/api/auth/google/callback/route.ts @@ -3,6 +3,7 @@ import { withRateLimit } from '@/lib/ratelimit'; import { exchangeCodeForToken, getGoogleUser, getGoogleAvatarUrl } from '@/lib/google/oauth'; import type { AuthResponseDTO, AuthErrorDTO } from '@/types/api/auth.dto'; import { edgeLog } from '@/../infra/edge-config'; +import { createLogger } from '@/lib/logging'; export const runtime = 'edge'; @@ -95,8 +96,9 @@ export async function GET( return addHeaders(response) as NextResponse; } catch (error) { + const logger = createLogger('api.auth.google-callback'); edgeLog('error', '/api/auth/google/callback', `Error: ${error}`); - console.error('Google OAuth callback error:', error); + logger.error('Google OAuth callback error', { error }); return addHeaders( NextResponse.json({ message: 'Internal server error' }, { status: 500 }), diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index cca50d4c..05e90de4 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -74,8 +74,6 @@ export async function POST( ); } catch (error) { logger.error('Login error', { error }); - - console.error('Login error:', error); return addHeaders(NextResponse.json({ message: 'Internal server error' }, { status: 500 })); } } diff --git a/src/app/api/performance/zoom-metrics/route.ts b/src/app/api/performance/zoom-metrics/route.ts index 9c03c456..58486de2 100644 --- a/src/app/api/performance/zoom-metrics/route.ts +++ b/src/app/api/performance/zoom-metrics/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { createLogger } from '@/lib/logging'; /** * API endpoint to expose Zoom integration performance metrics. @@ -49,7 +50,8 @@ export async function GET() { ], }); } catch (error) { - console.error('Failed to fetch Zoom metrics:', error); + const logger = createLogger('api.performance.zoom-metrics'); + logger.error('Failed to fetch Zoom metrics', { error }); return NextResponse.json( { success: false, message: 'Failed to fetch Zoom integration metrics' }, { status: 500 }, diff --git a/src/app/api/polls/route.ts b/src/app/api/polls/route.ts index c5ec48a1..5e0f0ba3 100644 --- a/src/app/api/polls/route.ts +++ b/src/app/api/polls/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { query } from '@/lib/db/pool'; +import { createLogger } from '@/lib/logging'; export async function GET(request: Request) { try { @@ -20,7 +21,8 @@ export async function GET(request: Request) { return NextResponse.json({ data: polls }); } catch (error) { - console.error('Failed to fetch polls:', error); + const logger = createLogger('api.polls'); + logger.error('Failed to fetch polls', { error }); return NextResponse.json({ error: 'Failed to fetch polls' }, { status: 500 }); } } @@ -57,7 +59,8 @@ export async function POST(request: Request) { return NextResponse.json({ data: result.rows[0] }, { status: 201 }); } catch (error) { - console.error('Failed to create poll:', error); + const logger = createLogger('api.polls'); + logger.error('Failed to create poll', { error }); return NextResponse.json({ error: 'Failed to create poll' }, { status: 500 }); } } diff --git a/src/app/components/dashboard/DashboardGrid.tsx b/src/app/components/dashboard/DashboardGrid.tsx index bdd96091..370d703c 100644 --- a/src/app/components/dashboard/DashboardGrid.tsx +++ b/src/app/components/dashboard/DashboardGrid.tsx @@ -11,6 +11,7 @@ import { useSensors, DragEndEvent, } from '@dnd-kit/core'; +import { createLogger } from '@/lib/logging'; import { arrayMove, SortableContext, @@ -156,7 +157,8 @@ export const DashboardGrid: React.FC = ({ try { localStorage.setItem('dashboard-widgets', JSON.stringify(widgets)); } catch (error) { - console.error('Error saving widget layout', error); + const logger = createLogger('components.dashboard.DashboardGrid'); + logger.error('Error saving widget layout', { error }); } finally { saveTimerRef.current = null; } diff --git a/src/app/error.tsx b/src/app/error.tsx index 26cfc0d5..cf8be2f9 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect } from 'react'; import { UserFriendlyErrorDisplay } from '@/components/errors/UserFriendlyErrorDisplay'; -import { errorReportingService } from '@/services/errorReporting'; +import { captureException, addBreadcrumb } from '@/lib/errors'; import { createLogger } from '@/lib/logging'; const logger = createLogger('ErrorPage'); @@ -13,14 +13,18 @@ export default function ErrorBoundary({ reset: () => void; }) { useEffect(() => { - // Log the error to an error reporting service - errorReportingService.addBreadcrumb('error.tsx', { - errorMessage: error.message, - digest: error.digest, + // Report the error through the structured error tracking system + addBreadcrumb({ + category: 'error.tsx', + message: error.message, + data: { digest: error.digest }, + level: 'error', }); - errorReportingService.reportError(error, { - errorInfo: { componentStack: '' }, - ...(error.digest ? { digest: error.digest } : {}), + captureException(error, { + extra: { + errorInfo: { componentStack: '' }, + ...(error.digest ? { digest: error.digest } : {}), + }, }); logger.error('Application error', { error }); }, [error]); diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index df08212b..7d6c1afc 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { UserFriendlyErrorDisplay } from '@/components/errors/UserFriendlyErrorDisplay'; -import { errorReportingService } from '@/services/errorReporting'; +import { captureException, addBreadcrumb } from '@/lib/errors'; export default function GlobalError({ error, @@ -12,11 +12,16 @@ export default function GlobalError({ reset: () => void; }) { useEffect(() => { - errorReportingService.addBreadcrumb('global-error', { - errorMessage: error.message, - digest: error.digest, + // Report the error through the structured error tracking system + addBreadcrumb({ + category: 'global-error', + message: error.message, + data: { digest: error.digest }, + level: 'error', + }); + captureException(error, { + extra: { digest: error.digest }, }); - errorReportingService.reportError(error); }, [error]); return ( diff --git a/src/app/hooks/useDashboardWidgets.tsx b/src/app/hooks/useDashboardWidgets.tsx index d8173d90..ea0a13ef 100644 --- a/src/app/hooks/useDashboardWidgets.tsx +++ b/src/app/hooks/useDashboardWidgets.tsx @@ -53,14 +53,13 @@ export const useDashboardWidgets = () => { if (!pending) return; localStorage.setItem('dashboard-widgets', JSON.stringify(pending)); } catch (error) { - console.error('Failed to save widget layout:', error); + logger.error('Failed to save widget layout', { error }); } finally { saveTimerRef.current = null; } }, 500); } catch (error) { - logger.error('Failed to save widget layout', { error }); - console.error('Failed to schedule dashboard layout save:', error); + logger.error('Failed to schedule dashboard layout save', { error }); } }, []); diff --git a/src/hooks/useVideoPlayerLazy.ts b/src/hooks/useVideoPlayerLazy.ts index a9dcaf35..53f532f7 100644 --- a/src/hooks/useVideoPlayerLazy.ts +++ b/src/hooks/useVideoPlayerLazy.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { VideoSource } from '@/components/video/types'; import { clampSeekTime } from '@/utils/videoPlayerUtils'; +import { createLogger } from '@/lib/logging'; type UseVideoPlayerOptions = { sources: VideoSource[]; @@ -66,7 +67,8 @@ export const useVideoPlayerLazy = ({ sources, poster }: UseVideoPlayerOptions) = setIsReady(true); } } catch (error) { - console.error('Failed to load video.js:', error); + const logger = createLogger('hooks.useVideoPlayerLazy'); + logger.error('Failed to load video.js', { error }); } }; diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 00000000..4732ccff --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,57 @@ +/** + * Next.js Instrumentation Entry Point + * + * This file is loaded exactly once on the server when the Next.js app starts. + * It is the ideal place to initialise cross-cutting concerns such as error + * tracking, performance monitoring, and structured logging. + * + * Next.js automatically detects `src/instrumentation.ts` (or + * `instrumentation.ts` at the project root) when + * `experimental.instrumentationHook` is enabled in `next.config.ts`. + * + * @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation + */ + +import { init as initErrorTracking } from '@/lib/errors'; + +// Sentry DSN — set NEXT_PUBLIC_SENTRY_DSN (client + server) or SENTRY_DSN +// (server-only) in your environment. When unset, error tracking still works +// locally via the structured logger and in-memory transport. +const SENTRY_DSN = + process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN || undefined; + +/** + * Register all instrumentation hooks. + * + * `register()` runs on the server during startup. Code here should be + * lightweight and side-effect-free beyond configuration. + */ +export function register() { + // Initialise the error tracking system with the configured DSN. + // This sets up global error handlers, configures the reporting service, + // and enables structured error logging. + initErrorTracking(SENTRY_DSN); + + // Log startup so the structured logger captures the boot event. + const { createLogger } = require('@/lib/logging'); + const logger = createLogger('instrumentation'); + logger.info('Application instrumentation complete', { + context: { + environment: process.env.NODE_ENV, + hasSentryDsn: !!SENTRY_DSN, + edgeRegion: process.env.EDGE_REGION || 'default', + }, + }); +} + +/** + * Optional: report errors that occur during server component rendering. + * This hook is called when an error happens during the React render cycle + * on the server. + */ +export async function onError(error: unknown) { + const { captureException } = await import('@/lib/errors'); + captureException(error, { + extra: { source: 'server-render' }, + }); +} diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts index 6cd9eb8a..8349a6ee 100644 --- a/src/lib/auth/jwt.ts +++ b/src/lib/auth/jwt.ts @@ -1,5 +1,6 @@ import { SignJWT } from 'jose'; import { UserRole } from '@/types/api'; +import { createLogger } from '@/lib/logging'; export interface JWTPayload { sub: string; @@ -38,7 +39,8 @@ export async function verifyToken(token: string | undefined | null): Promise { - console.error('Migration failed:', err); + const logger = createLogger('lib.db.migrate'); + logger.error('Migration failed', { error: err }); process.exit(1); }); } diff --git a/src/lib/errors/__tests__/index.test.ts b/src/lib/errors/__tests__/index.test.ts new file mode 100644 index 00000000..c15da556 --- /dev/null +++ b/src/lib/errors/__tests__/index.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + init, + captureException, + captureMessage, + addBreadcrumb, + setUser, + getBreadcrumbs, + isInitialized, + _resetForTesting, + errorReportingService, +} from '../index'; +import { queryLogs } from '@/lib/logging'; + +describe('error tracking public API', () => { + beforeEach(() => { + // Reset the singleton state between tests + _resetForTesting(); + errorReportingService.clearBreadcrumbs(); + errorReportingService.clearUserId(); + errorReportingService.clearBreadcrumbs(); + + // Reset the in-memory log store so each test starts clean + globalThis.__TEACHLINK_LOG_RECORDS__ = []; + }); + + describe('init()', () => { + it('marks the system as initialized', () => { + expect(isInitialized()).toBe(false); + init(); + expect(isInitialized()).toBe(true); + }); + + it('is idempotent — calling twice does not re-initialize', () => { + init('https://dsn@example.com'); + const firstState = isInitialized(); + init(); // second call should be a no-op + expect(isInitialized()).toBe(firstState); + }); + + it('passes the DSN to the reporting service', () => { + const configureSpy = vi.spyOn(errorReportingService, 'configure'); + init('https://examplePublicKey@o0.ingest.sentry.io/0'); + expect(configureSpy).toHaveBeenCalledWith({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + }); + configureSpy.mockRestore(); + }); + + it('passes null DSN when no DSN is provided', () => { + const configureSpy = vi.spyOn(errorReportingService, 'configure'); + init(); + expect(configureSpy).toHaveBeenCalledWith({ dsn: null }); + configureSpy.mockRestore(); + }); + }); + + describe('captureException()', () => { + beforeEach(() => { + init(); + }); + + it('reports an Error to the reporting service', async () => { + const reportSpy = vi + .spyOn(errorReportingService, 'reportError') + .mockResolvedValue({} as any); + + const error = new Error('test failure'); + captureException(error); + + expect(reportSpy).toHaveBeenCalledTimes(1); + expect(reportSpy).toHaveBeenCalledWith(error, undefined); + reportSpy.mockRestore(); + }); + + it('wraps non-Error values in an Error', async () => { + const reportSpy = vi + .spyOn(errorReportingService, 'reportError') + .mockResolvedValue({} as any); + + captureException('string error'); + + const reportedError = reportSpy.mock.calls[0]?.[0]; + expect(reportedError).toBeInstanceOf(Error); + expect(reportedError.message).toBe('string error'); + reportSpy.mockRestore(); + }); + + it('passes context tags and extra to the reporting service', async () => { + const reportSpy = vi + .spyOn(errorReportingService, 'reportError') + .mockResolvedValue({} as any); + + const error = new Error('tagged error'); + captureException(error, { + userId: 'usr_123', + tags: { feature: 'billing' }, + extra: { orderId: 'ord_456' }, + }); + + expect(reportSpy).toHaveBeenCalledWith(error, { + tags: { feature: 'billing' }, + extra: { orderId: 'ord_456' }, + }); + reportSpy.mockRestore(); + }); + + it('sets the user ID when context.userId is provided', async () => { + const setUserIdSpy = vi.spyOn(errorReportingService, 'setUserId'); + const reportSpy = vi + .spyOn(errorReportingService, 'reportError') + .mockResolvedValue({} as any); + + captureException(new Error('user error'), { userId: 'usr_789' }); + + expect(setUserIdSpy).toHaveBeenCalledWith('usr_789'); + reportSpy.mockRestore(); + setUserIdSpy.mockRestore(); + }); + + it('adds a breadcrumb for the exception', () => { + const addBreadcrumbSpy = vi.spyOn(errorReportingService, 'addBreadcrumb'); + + captureException(new Error('breadcrumb error')); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + 'exception', + expect.objectContaining({ + message: 'breadcrumb error', + level: 'error', + }), + ); + addBreadcrumbSpy.mockRestore(); + }); + }); + + describe('captureMessage()', () => { + beforeEach(() => { + init(); + }); + + it('reports a message as an Error to the reporting service', async () => { + const reportSpy = vi + .spyOn(errorReportingService, 'reportError') + .mockResolvedValue({} as any); + + captureMessage('something happened'); + + const reportedError = reportSpy.mock.calls[0]?.[0]; + expect(reportedError).toBeInstanceOf(Error); + expect(reportedError.message).toBe('something happened'); + reportSpy.mockRestore(); + }); + + it('passes context to the reporting service', async () => { + const reportSpy = vi + .spyOn(errorReportingService, 'reportError') + .mockResolvedValue({} as any); + + captureMessage('info message', { tags: { page: 'home' } }); + + expect(reportSpy).toHaveBeenCalledTimes(1); + reportSpy.mockRestore(); + }); + }); + + describe('addBreadcrumb()', () => { + beforeEach(() => { + init(); + }); + + it('forwards breadcrumbs to the reporting service', () => { + const spy = vi.spyOn(errorReportingService, 'addBreadcrumb'); + + addBreadcrumb({ + category: 'navigation', + message: 'User clicked button', + data: { buttonId: 'submit' }, + level: 'info', + }); + + expect(spy).toHaveBeenCalledWith('navigation', { + message: 'User clicked button', + level: 'info', + buttonId: 'submit', + }); + spy.mockRestore(); + }); + }); + + describe('setUser()', () => { + beforeEach(() => { + init(); + }); + + it('sets the user ID on the reporting service', () => { + const spy = vi.spyOn(errorReportingService, 'setUserId'); + + setUser({ id: 'usr_123', email: 'test@example.com' }); + + expect(spy).toHaveBeenCalledWith('usr_123'); + spy.mockRestore(); + }); + + it('clears the user ID when null is passed', () => { + const spy = vi.spyOn(errorReportingService, 'clearUserId'); + + setUser(null); + + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + }); + + describe('getBreadcrumbs()', () => { + beforeEach(() => { + init(); + }); + + it('returns breadcrumbs from the reporting service', () => { + errorReportingService.addBreadcrumb('test-action', { detail: 'test' }); + + const breadcrumbs = getBreadcrumbs(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.action).toBe('test-action'); + }); + }); + + describe('isInitialized()', () => { + it('returns false before init is called', () => { + expect(isInitialized()).toBe(false); + }); + + it('returns true after init is called', () => { + init(); + expect(isInitialized()).toBe(true); + }); + }); + + describe('structured logging integration', () => { + beforeEach(() => { + init(); + }); + + it('writes error reports to the structured logger', async () => { + const error = new Error('logged error'); + await captureException(error); + + const errorLogs = queryLogs({ level: 'error', scope: 'error-reporting' }); + expect(errorLogs.length).toBeGreaterThan(0); + expect(errorLogs[0]?.message).toContain('Error Report'); + }); + + it('includes the error message in the log record', async () => { + const error = new Error('specific error message'); + await captureException(error); + + const errorLogs = queryLogs({ level: 'error', scope: 'error-reporting' }); + const logContent = JSON.stringify(errorLogs[0]); + expect(logContent).toContain('specific error message'); + }); + }); +}); diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index 54484062..97cd725e 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -1,13 +1,41 @@ /** * Error Tracking Integration (#327) * - * Wires a Sentry-compatible interface over the existing errorReportingService. - * Drop-in: swap the stub init() for a real Sentry.init() call when the SDK - * is installed, without changing any call-sites. + * Enhanced error tracking using existing infrastructure with structured logging, + * breadcrumbs, and server-side reporting. Provides Sentry-compatible interface + * for easy migration to Sentry in the future if needed. + * + * ## Architecture + * + * The error tracking system has three layers: + * + * 1. **Public API** (`src/lib/errors/index.ts`) — Sentry-compatible functions + * (`init`, `captureException`, `captureMessage`, `addBreadcrumb`, `setUser`, + * `getBreadcrumbs`) that application code calls. + * + * 2. **ErrorReportingService** (`src/services/errorReporting.ts`) — Singleton + * that manages breadcrumbs, session IDs, user identity, and dispatches + * error reports to the structured logging system and (in production) the + * `/api/errors/report` endpoint. + * + * 3. **Structured Logging** (`src/lib/logging`) — Pino-based logger with PII + * redaction, correlation IDs, and multi-transport support (in-memory, HTTP). + * + * ## Configuration + * + * Set `NEXT_PUBLIC_SENTRY_DSN` (or `SENTRY_DSN` on the server) to enable + * remote error reporting. When no DSN is configured, errors are still logged + * locally via the structured logger and stored in the in-memory transport. + * + * ## Initialization + * + * Call `init()` once at application startup via `src/instrumentation.ts`. + * The instrumentation file is automatically loaded by Next.js. */ import { errorReportingService, BreadcrumbEntry } from '@/services/errorReporting'; import { createLogger } from '@/lib/logging'; + const logger = createLogger('ErrorTracking'); // ── Types ──────────────────────────────────────────────────────────────────── @@ -27,22 +55,31 @@ export interface Breadcrumb { // ── Sentry-compatible stub ──────────────────────────────────────────────────── // Replace the body of each function with the real Sentry SDK call once -// `@sentry/nextjs` is installed. +// `@sentry/nextjs` is installed. The current implementation delegates to the +// internal ErrorReportingService which provides structured logging, breadcrumb +// tracking, and server-side reporting. let _initialized = false; /** * Initialise the error tracking SDK. * Call once at application startup (e.g. in instrumentation.ts). + * + * @param dsn - Optional Sentry DSN. When provided, error reports are also + * sent to the remote Sentry endpoint. When omitted, errors are logged + * locally via the structured logger and stored in-memory. */ export function init(dsn?: string): void { if (_initialized) return; _initialized = true; - // TODO: replace with Sentry.init({ dsn, ... }) when SDK is installed. - if (dsn) { - logger.info('[ErrorTracking] Initialised with DSN', { context: { dsn } }); - } + logger.info('[ErrorTracking] Initialized with structured error tracking', { + context: { hasDsn: !!dsn, environment: process.env.NODE_ENV }, + }); + + // Configure the DSN on the reporting service so it knows whether to + // attempt remote delivery. + errorReportingService.configure({ dsn: dsn ?? null }); // Forward global unhandled errors to the reporting service automatically. if (typeof window !== 'undefined') { @@ -119,5 +156,19 @@ export function getBreadcrumbs(): BreadcrumbEntry[] { return errorReportingService.getBreadcrumbs(); } +/** + * Check whether the error tracking system has been initialized. + */ +export function isInitialized(): boolean { + return _initialized; +} + +/** + * Reset the initialization state (primarily for testing). + */ +export function _resetForTesting(): void { + _initialized = false; +} + // Re-export the underlying service for advanced use-cases. export { errorReportingService }; diff --git a/src/services/errorReporting.ts b/src/services/errorReporting.ts index 9e8cbbaa..02dd3ba3 100644 --- a/src/services/errorReporting.ts +++ b/src/services/errorReporting.ts @@ -1,6 +1,16 @@ /** * Error Reporting Service - * Handles error logging, analytics, and debugging insights + * Handles error logging, analytics, and debugging insights. + * + * This service is the engine behind the Sentry-compatible public API in + * `src/lib/errors/index.ts`. It integrates with the structured logging + * system (`src/lib/logging`) so that every error report is: + * + * - Emitted as a structured JSON log record (via the Pino logger) + * - Stored in the in-memory transport for diagnostics/testing + * - Optionally forwarded to an HTTP aggregation endpoint + * - Optionally sent to `/api/errors/report` in production for server-side + * persistence with PII redaction and rate limiting */ import { formatErrorForLogging } from '@/utils/errorUtils'; @@ -26,12 +36,22 @@ export interface BreadcrumbEntry { details?: Record; } +export interface ErrorReportingConfig { + /** + * Optional Sentry DSN. When set, the service knows a remote error + * tracking backend is available. The actual Sentry SDK integration + * would replace the `sendToServer` call in a future migration. + */ + dsn: string | null; +} + class ErrorReportingService { private breadcrumbs: BreadcrumbEntry[] = []; private maxBreadcrumbs = 50; private sessionId: string; private userId?: string; private isProduction: boolean; + private config: ErrorReportingConfig = { dsn: null }; constructor() { this.sessionId = this.generateSessionId(); @@ -39,6 +59,14 @@ class ErrorReportingService { this.setupGlobalErrorHandlers(); } + /** + * Update runtime configuration (DSN, etc.). + * Called by the public API `init()` function. + */ + configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + /** * Generate a unique session ID */ @@ -96,12 +124,19 @@ class ErrorReportingService { async reportError(error: any, context?: Record): Promise { const report = this.createErrorReport(error, context); - // Log to console in development - if (!this.isProduction) { - logger.error('Error Report', { error: report }); - } + // Always log to the structured logger — this feeds the in-memory transport, + // the HTTP transport (if configured), and the console via Pino. + logger.error('Error Report', { + context: { + ...report, + hasDsn: !!this.config.dsn, + isProduction: this.isProduction, + }, + error, + }); - // Send to error tracking service (e.g., Sentry, LogRocket) + // In production, also send to the server-side reporting endpoint for + // persistent storage with PII redaction and rate limiting. if (this.isProduction) { await this.sendToServer(report); } @@ -145,7 +180,7 @@ class ErrorReportingService { }); if (!response.ok) { - logger.error('Failed to send error report', { status: response.statusText }); + logger.warn('Failed to send error report', { status: response.statusText }); } } catch (err) { logger.error('Error sending error report', { error: err });