From f28e2e37c00f19e1b2092ef36c8fdb94ad2a0869 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 19 Feb 2026 16:10:35 -0800 Subject: [PATCH] feat: error handling and logger --- api/src/app/routes/log/index.ts | 40 ++++++ api/src/app/utils/logger.ts | 9 ++ api/src/main.ts | 35 ++++- apps/frontend/src/app/app.config.ts | 8 ++ .../console-logger.initializer.ts | 13 ++ .../app/services/console-logger.service.ts | 127 ++++++++++++++++++ dev.env | 5 +- package.json | 1 + pnpm-lock.yaml | 8 ++ prod.env | 5 +- 10 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 api/src/app/routes/log/index.ts create mode 100644 api/src/app/utils/logger.ts create mode 100644 apps/frontend/src/app/initializers/console-logger.initializer.ts create mode 100644 apps/frontend/src/app/services/console-logger.service.ts diff --git a/api/src/app/routes/log/index.ts b/api/src/app/routes/log/index.ts new file mode 100644 index 0000000..9cdfc6f --- /dev/null +++ b/api/src/app/routes/log/index.ts @@ -0,0 +1,40 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { logger } from '../../utils/logger'; + +interface LogBody { + level: 'debug' | 'info' | 'warn' | 'error'; + message: string; + context?: string; + error?: { + name: string; + message: string; + stack?: string; + }; +} + +export default async function (fastify: FastifyInstance) { + fastify.post('/log', async function (request: FastifyRequest<{ Body: LogBody }>) { + const { level, message, context, error } = request.body; + + if (level === 'error' && error) { + const errorObj = new Error(error.message); + errorObj.name = error.name; + if (error.stack) { + errorObj.stack = error.stack; + } + await logger.error(context || 'Frontend', errorObj); + } else if (level === 'error') { + await logger.log('warn', `[Frontend Error] ${message}`); + } else { + await logger.log(level, `[Frontend] ${message}`); + } + + return { success: true }; + }); +} diff --git a/api/src/app/utils/logger.ts b/api/src/app/utils/logger.ts new file mode 100644 index 0000000..10d3df6 --- /dev/null +++ b/api/src/app/utils/logger.ts @@ -0,0 +1,9 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Logger } from "@nhcarrigan/logger"; + +export const logger = new Logger("Library", process.env.LOG_TOKEN ?? ""); \ No newline at end of file diff --git a/api/src/main.ts b/api/src/main.ts index b54fdb7..3270acd 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,9 +1,42 @@ import Fastify from 'fastify'; import { app } from './app/app'; +import { logger } from './app/utils/logger'; const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 12321; +// Global error handlers +process.on('uncaughtException', (error: Error) => { + void logger.error('Uncaught Exception', error); + process.exit(1); +}); + +process.on('unhandledRejection', (reason: unknown) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + void logger.error('Unhandled Rejection', error); + process.exit(1); +}); + +process.on('warning', (warning: Error) => { + void logger.log('warn', `Process Warning: ${warning.name} - ${warning.message}`); +}); + +process.on('SIGTERM', () => { + void logger.log('info', 'SIGTERM signal received: closing HTTP server'); + server.close(() => { + void logger.log('info', 'HTTP server closed'); + process.exit(0); + }); +}); + +process.on('SIGINT', () => { + void logger.log('info', 'SIGINT signal received: closing HTTP server'); + server.close(() => { + void logger.log('info', 'HTTP server closed'); + process.exit(0); + }); +}); + // Instantiate Fastify with some config const server = Fastify({ logger: true, @@ -19,6 +52,6 @@ server.listen({ port, host }, (err) => { server.log.error(err); process.exit(1); } else { - console.log(`[ ready ] http://${host}:${port}`); + void logger.log('info', `Server ready at http://${host}:${port}`); } }); diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 50f874a..5e709b8 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -11,6 +11,8 @@ import { AuthService } from './services/auth.service'; import { AuthInterceptor } from './interceptors/auth.interceptor'; import { initializeAuth } from './initializers/auth.initializer'; import { GlobalErrorHandler } from './services/global-error-handler.service'; +import { ConsoleLoggerService } from './services/console-logger.service'; +import { initializeConsoleLogger } from './initializers/console-logger.initializer'; export const appConfig: ApplicationConfig = { providers: [ @@ -31,6 +33,12 @@ export const appConfig: ApplicationConfig = { useFactory: initializeAuth, deps: [AuthService], multi: true + }, + { + provide: APP_INITIALIZER, + useFactory: initializeConsoleLogger, + deps: [ConsoleLoggerService], + multi: true } ], }; diff --git a/apps/frontend/src/app/initializers/console-logger.initializer.ts b/apps/frontend/src/app/initializers/console-logger.initializer.ts new file mode 100644 index 0000000..3145fc1 --- /dev/null +++ b/apps/frontend/src/app/initializers/console-logger.initializer.ts @@ -0,0 +1,13 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { ConsoleLoggerService } from '../services/console-logger.service'; + +export function initializeConsoleLogger(consoleLogger: ConsoleLoggerService) { + return () => { + consoleLogger.initialise(); + }; +} diff --git a/apps/frontend/src/app/services/console-logger.service.ts b/apps/frontend/src/app/services/console-logger.service.ts new file mode 100644 index 0000000..89f311e --- /dev/null +++ b/apps/frontend/src/app/services/console-logger.service.ts @@ -0,0 +1,127 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../environments/environment'; + +interface LogPayload { + level: 'debug' | 'info' | 'warn' | 'error'; + message: string; + context?: string; + error?: { + name: string; + message: string; + stack?: string; + }; +} + +@Injectable({ + providedIn: 'root' +}) +export class ConsoleLoggerService { + private http = inject(HttpClient); + private originalConsole = { + log: console.log.bind(console), + error: console.error.bind(console), + warn: console.warn.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console) + }; + + /** + * Initialises the console override to pipe logs to the API. + */ + initialise(): void { + console.log = (...args: unknown[]) => { + this.originalConsole.log(...args); + this.sendLog('info', this.formatArgs(args)); + }; + + console.info = (...args: unknown[]) => { + this.originalConsole.info(...args); + this.sendLog('info', this.formatArgs(args)); + }; + + console.debug = (...args: unknown[]) => { + this.originalConsole.debug(...args); + this.sendLog('debug', this.formatArgs(args)); + }; + + console.warn = (...args: unknown[]) => { + this.originalConsole.warn(...args); + this.sendLog('warn', this.formatArgs(args)); + }; + + console.error = (...args: unknown[]) => { + this.originalConsole.error(...args); + + // Check if the first argument is an Error object + if (args[0] instanceof Error) { + const error = args[0]; + this.sendLog('error', error.message, 'Console', { + name: error.name, + message: error.message, + stack: error.stack + }); + } else { + this.sendLog('error', this.formatArgs(args)); + } + }; + + // Global error handlers + window.addEventListener('error', (event: ErrorEvent) => { + this.originalConsole.error('Uncaught Error:', event.error); + this.sendLog('error', event.message, 'Window Error', { + name: event.error?.name || 'Error', + message: event.message, + stack: event.error?.stack + }); + }); + + window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { + this.originalConsole.error('Unhandled Promise Rejection:', event.reason); + + const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason)); + this.sendLog('error', error.message, 'Unhandled Rejection', { + name: error.name, + message: error.message, + stack: error.stack + }); + }); + } + + private formatArgs(args: unknown[]): string { + return args.map(arg => { + if (typeof arg === 'string') { + return arg; + } + if (arg instanceof Error) { + return `${arg.name}: ${arg.message}`; + } + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }).join(' '); + } + + private sendLog(level: LogPayload['level'], message: string, context?: string, error?: LogPayload['error']): void { + const payload: LogPayload = { + level, + message, + context, + error + }; + + this.http.post(`${environment.apiUrl}/log`, payload).subscribe({ + error: (err) => { + this.originalConsole.error('Failed to send log to API:', err); + } + }); + } +} diff --git a/dev.env b/dev.env index 2d3b58e..d0fc8c4 100644 --- a/dev.env +++ b/dev.env @@ -18,4 +18,7 @@ MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id" STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id" # Application URL -BASE_URL="op://Environment Variables - Naomi/Library/localhost url" \ No newline at end of file +BASE_URL="op://Environment Variables - Naomi/Library/localhost url" + +# Logger +LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file diff --git a/package.json b/package.json index eda46ea..31b699c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "6.0.4", "@fastify/static": "^9.0.0", + "@nhcarrigan/logger": "1.1.1", "@prisma/client": "6.19.2", "dompurify": "^3.3.1", "fastify": "5.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcea136..afd8d8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@fastify/static': specifier: ^9.0.0 version: 9.0.0 + '@nhcarrigan/logger': + specifier: 1.1.1 + version: 1.1.1 '@prisma/client': specifier: 6.19.2 version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) @@ -2488,6 +2491,9 @@ packages: typescript: '>=5' vitest: '>=2' + '@nhcarrigan/logger@1.1.1': + resolution: {integrity: sha512-P6OEQFHDtf6psybYGljuCxkSW6DLQCsx1aZZ3w4YKBXHBFjDbhuvpM9K1kPhVN48hakitx2WPLEoIFr6YZELYw==} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -12722,6 +12728,8 @@ snapshots: - eslint-import-resolver-webpack - supports-color + '@nhcarrigan/logger@1.1.1': {} + '@noble/hashes@1.4.0': {} '@nodelib/fs.scandir@2.1.5': diff --git a/prod.env b/prod.env index f49d9e2..6e20d68 100644 --- a/prod.env +++ b/prod.env @@ -18,4 +18,7 @@ MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id" STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id" # Application URL -BASE_URL="op://Environment Variables - Naomi/Library/base url" \ No newline at end of file +BASE_URL="op://Environment Variables - Naomi/Library/base url" + +# Logger +LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file