From 5e1118b7117f520d65edd90fa114b6a0b732760e Mon Sep 17 00:00:00 2001 From: Wuesteon Date: Fri, 19 Dec 2025 02:17:36 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(error-tracking):=20add=20share?= =?UTF-8?q?d=20error=20tracking=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @manacore/shared-error-tracking package with: - Frontend error tracker with batching and offline support - SvelteKit integration with hooks handler - Expo/React Native integration with global error handler - NestJS module with exception filter and service - Shared TypeScript types for error log entries --- packages/shared-error-tracking/package.json | 67 +++++ .../src/frontend/error-tracker.ts | 257 ++++++++++++++++++ .../src/frontend/expo.ts | 107 ++++++++ .../src/frontend/index.ts | 7 + .../src/frontend/sveltekit.ts | 79 ++++++ packages/shared-error-tracking/src/index.ts | 4 + .../src/nestjs/error-tracking.filter.ts | 118 ++++++++ .../src/nestjs/error-tracking.module.ts | 58 ++++ .../src/nestjs/error-tracking.service.ts | 194 +++++++++++++ .../shared-error-tracking/src/nestjs/index.ts | 7 + .../shared-error-tracking/src/types/index.ts | 111 ++++++++ packages/shared-error-tracking/tsconfig.json | 24 ++ 12 files changed, 1033 insertions(+) create mode 100644 packages/shared-error-tracking/package.json create mode 100644 packages/shared-error-tracking/src/frontend/error-tracker.ts create mode 100644 packages/shared-error-tracking/src/frontend/expo.ts create mode 100644 packages/shared-error-tracking/src/frontend/index.ts create mode 100644 packages/shared-error-tracking/src/frontend/sveltekit.ts create mode 100644 packages/shared-error-tracking/src/index.ts create mode 100644 packages/shared-error-tracking/src/nestjs/error-tracking.filter.ts create mode 100644 packages/shared-error-tracking/src/nestjs/error-tracking.module.ts create mode 100644 packages/shared-error-tracking/src/nestjs/error-tracking.service.ts create mode 100644 packages/shared-error-tracking/src/nestjs/index.ts create mode 100644 packages/shared-error-tracking/src/types/index.ts create mode 100644 packages/shared-error-tracking/tsconfig.json diff --git a/packages/shared-error-tracking/package.json b/packages/shared-error-tracking/package.json new file mode 100644 index 000000000..e53497aff --- /dev/null +++ b/packages/shared-error-tracking/package.json @@ -0,0 +1,67 @@ +{ + "name": "@manacore/shared-error-tracking", + "version": "1.0.0", + "description": "Centralized error tracking for ManaCore applications - NestJS and frontend clients", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./nestjs": { + "types": "./dist/nestjs/index.d.ts", + "default": "./dist/nestjs/index.js" + }, + "./frontend": { + "types": "./dist/frontend/index.d.ts", + "default": "./dist/frontend/index.js" + }, + "./types": { + "types": "./dist/types/index.d.ts", + "default": "./dist/types/index.js" + } + }, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm build", + "lint": "eslint .", + "type-check": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/config": "^3.0.0 || ^4.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/common": { + "optional": true + }, + "@nestjs/config": { + "optional": true + }, + "@nestjs/core": { + "optional": true + } + }, + "devDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.0.0", + "@nestjs/core": "^10.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "keywords": [ + "nestjs", + "error-tracking", + "sveltekit", + "expo", + "manacore" + ], + "author": "Mana Core Team", + "license": "MIT" +} diff --git a/packages/shared-error-tracking/src/frontend/error-tracker.ts b/packages/shared-error-tracking/src/frontend/error-tracker.ts new file mode 100644 index 000000000..52ece41ee --- /dev/null +++ b/packages/shared-error-tracking/src/frontend/error-tracker.ts @@ -0,0 +1,257 @@ +import type { + ErrorTrackingConfig, + ErrorLogPayload, + ErrorContext, + CreateErrorLogResponse, + ErrorSourceType, +} from '../types'; + +/** + * Frontend error tracker client + */ +export class ErrorTracker { + private config: ErrorTrackingConfig; + private queue: ErrorLogPayload[] = []; + private isFlushing = false; + + constructor(config: ErrorTrackingConfig) { + this.config = config; + } + + /** + * Capture an error and send it to the tracking service + */ + async captureError( + error: Error | unknown, + context?: ErrorContext + ): Promise { + const payload = this.buildPayload(error, context); + + // Log locally if enabled + if (this.config.enableLocalLogging !== false) { + console.error(`[${payload.errorCode}] ${payload.message}`, error); + } + + return this.sendError(payload); + } + + /** + * Capture a message as an error + */ + async captureMessage( + message: string, + severity: 'debug' | 'info' | 'warning' | 'error' | 'critical' = 'info', + context?: ErrorContext + ): Promise { + const payload: ErrorLogPayload = { + errorCode: 'MESSAGE', + errorType: 'CapturedMessage', + message, + severity, + context, + appId: this.config.appId, + serviceName: this.config.serviceName, + sourceType: this.detectSourceType(), + environment: this.config.environment || this.detectEnvironment(), + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + browserInfo: this.getBrowserInfo(), + occurredAt: new Date().toISOString(), + }; + + return this.sendError(payload); + } + + /** + * Queue an error for batch sending (useful for offline scenarios) + */ + queueError(error: Error | unknown, context?: ErrorContext): void { + const payload = this.buildPayload(error, context); + this.queue.push(payload); + } + + /** + * Flush queued errors to the tracking service + */ + async flushQueue(): Promise { + if (this.isFlushing || this.queue.length === 0) return; + + this.isFlushing = true; + const errors = [...this.queue]; + this.queue = []; + + try { + await this.sendBatch(errors); + } catch { + // Re-queue failed errors + this.queue.unshift(...errors); + } finally { + this.isFlushing = false; + } + } + + /** + * Build error payload from error object + */ + private buildPayload(error: Error | unknown, context?: ErrorContext): ErrorLogPayload { + const err = error instanceof Error ? error : new Error(String(error)); + + return { + errorCode: this.extractErrorCode(err), + errorType: err.constructor.name, + message: err.message, + stackTrace: err.stack, + severity: 'error', + context, + appId: this.config.appId, + serviceName: this.config.serviceName, + sourceType: this.detectSourceType(), + environment: this.config.environment || this.detectEnvironment(), + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + browserInfo: this.getBrowserInfo(), + occurredAt: new Date().toISOString(), + }; + } + + /** + * Send a single error to the tracking service + */ + private async sendError(payload: ErrorLogPayload): Promise { + try { + const url = `${this.config.errorTrackingUrl}/api/v1/errors`; + const headers = await this.buildHeaders(); + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}` }; + } + + return (await response.json()) as CreateErrorLogResponse; + } catch (err) { + console.warn('Failed to send error to tracking service', err); + return { success: false, error: 'Network error' }; + } + } + + /** + * Send batch errors to the tracking service + */ + private async sendBatch(errors: ErrorLogPayload[]): Promise { + const url = `${this.config.errorTrackingUrl}/api/v1/errors/batch`; + const headers = await this.buildHeaders(); + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ errors }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + } + + /** + * Build request headers + */ + private async buildHeaders(): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-App-Id': this.config.appId, + ...this.config.customHeaders, + }; + + if (this.config.getAuthToken) { + const token = await this.config.getAuthToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + return headers; + } + + /** + * Detect source type based on environment + */ + private detectSourceType(): ErrorSourceType { + if (typeof window === 'undefined') { + return 'backend'; // SSR or Node.js + } + // Check for React Native + if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') { + return 'frontend_mobile'; + } + return 'frontend_web'; + } + + /** + * Detect current environment + */ + private detectEnvironment(): 'development' | 'staging' | 'production' { + if (typeof process !== 'undefined' && process.env) { + const nodeEnv = process.env.NODE_ENV; + if (nodeEnv === 'production') return 'production'; + if (nodeEnv === 'staging') return 'staging'; + } + // Check for common dev indicators + if (typeof window !== 'undefined') { + const hostname = window.location?.hostname; + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return 'development'; + } + if (hostname?.includes('staging') || hostname?.includes('stage')) { + return 'staging'; + } + } + return 'production'; + } + + /** + * Extract error code from error object + */ + private extractErrorCode(error: Error): string { + const anyError = error as unknown as Record; + if (anyError.code && typeof anyError.code === 'string') { + return anyError.code; + } + if (anyError.name && typeof anyError.name === 'string') { + return anyError.name; + } + return 'UNKNOWN_ERROR'; + } + + /** + * Get browser information + */ + private getBrowserInfo(): Record | undefined { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return undefined; + } + + return { + userAgent: navigator.userAgent, + language: navigator.language, + platform: navigator.platform, + cookieEnabled: navigator.cookieEnabled, + onLine: navigator.onLine, + url: window.location?.href, + referrer: document?.referrer, + screenWidth: window.screen?.width, + screenHeight: window.screen?.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; + } +} + +/** + * Create an error tracker instance + */ +export function createErrorTracker(config: ErrorTrackingConfig): ErrorTracker { + return new ErrorTracker(config); +} diff --git a/packages/shared-error-tracking/src/frontend/expo.ts b/packages/shared-error-tracking/src/frontend/expo.ts new file mode 100644 index 000000000..d2892502e --- /dev/null +++ b/packages/shared-error-tracking/src/frontend/expo.ts @@ -0,0 +1,107 @@ +import type { ErrorTracker } from './error-tracker'; + +/** + * Create an Expo/React Native error handler for react-native-error-boundary + * + * Usage: + * ```typescript + * // App.tsx + * import ErrorBoundary from 'react-native-error-boundary'; + * import { createExpoErrorHandler } from '@manacore/shared-error-tracking/frontend'; + * import { errorTracker } from '@/lib/error-tracking'; + * + * const { errorHandler, ErrorFallback } = createExpoErrorHandler(errorTracker); + * + * export default function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ +export function createExpoErrorHandler(errorTracker: ErrorTracker) { + const errorHandler = (error: Error, stackTrace: string) => { + void errorTracker.captureError(error, { + type: 'error_boundary', + stackTrace, + }); + }; + + return { + errorHandler, + }; +} + +/** + * Get device info for React Native + * This is a simple implementation - for more detailed info, + * consider using react-native-device-info package + */ +export function getReactNativeDeviceInfo(): Record { + const info: Record = { + platform: 'react-native', + }; + + // Add Platform info if available + try { + // Dynamic import to avoid issues in non-RN environments + const Platform = require('react-native').Platform; + info.os = Platform.OS; + info.version = Platform.Version; + info.isTV = Platform.isTV; + } catch { + // Platform not available + } + + // Add Dimensions if available + try { + const Dimensions = require('react-native').Dimensions; + const { width, height } = Dimensions.get('window'); + info.screenWidth = width; + info.screenHeight = height; + } catch { + // Dimensions not available + } + + return info; +} + +/** + * Setup global error handler for React Native + * Call this in your app entry point + * + * Usage: + * ```typescript + * // index.js or App.tsx + * import { setupReactNativeErrorHandler } from '@manacore/shared-error-tracking/frontend'; + * import { errorTracker } from '@/lib/error-tracking'; + * + * setupReactNativeErrorHandler(errorTracker); + * ``` + */ +export function setupReactNativeErrorHandler(errorTracker: ErrorTracker): void { + // Override the default error handler + const originalHandler = ErrorUtils.getGlobalHandler(); + + ErrorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => { + // Capture the error + void errorTracker.captureError(error, { + type: 'global_error', + isFatal, + deviceInfo: getReactNativeDeviceInfo(), + }); + + // Call the original handler + if (originalHandler) { + originalHandler(error, isFatal); + } + }); +} + +// Type declaration for React Native's ErrorUtils +declare const ErrorUtils: { + getGlobalHandler: () => ((error: Error, isFatal?: boolean) => void) | undefined; + setGlobalHandler: (handler: (error: Error, isFatal?: boolean) => void) => void; +}; diff --git a/packages/shared-error-tracking/src/frontend/index.ts b/packages/shared-error-tracking/src/frontend/index.ts new file mode 100644 index 000000000..ef27bf090 --- /dev/null +++ b/packages/shared-error-tracking/src/frontend/index.ts @@ -0,0 +1,7 @@ +export { ErrorTracker, createErrorTracker } from './error-tracker'; +export { createSvelteErrorHandler, setupGlobalErrorHandler } from './sveltekit'; +export { + createExpoErrorHandler, + getReactNativeDeviceInfo, + setupReactNativeErrorHandler, +} from './expo'; diff --git a/packages/shared-error-tracking/src/frontend/sveltekit.ts b/packages/shared-error-tracking/src/frontend/sveltekit.ts new file mode 100644 index 000000000..f7a013b4c --- /dev/null +++ b/packages/shared-error-tracking/src/frontend/sveltekit.ts @@ -0,0 +1,79 @@ +import type { ErrorTracker } from './error-tracker'; + +/** + * Create a SvelteKit error handler for hooks.client.ts + * + * Usage: + * ```typescript + * // src/hooks.client.ts + * import { createSvelteErrorHandler } from '@manacore/shared-error-tracking/frontend'; + * import { errorTracker } from '$lib/error-tracking'; + * + * export const handleError = createSvelteErrorHandler(errorTracker); + * ``` + */ +export function createSvelteErrorHandler(errorTracker: ErrorTracker) { + return async ({ + error, + event, + status, + message, + }: { + error: unknown; + event: { url: URL; params: Record; route: { id: string | null } }; + status: number; + message: string; + }) => { + // Capture the error + await errorTracker.captureError(error, { + status, + message, + url: event.url.toString(), + routeId: event.route.id, + params: event.params, + }); + + // Return standard SvelteKit error response + return { + message: message || 'An unexpected error occurred', + }; + }; +} + +/** + * Setup global error handler for unhandled errors and promise rejections + * Call this in hooks.client.ts + * + * Usage: + * ```typescript + * // src/hooks.client.ts + * import { setupGlobalErrorHandler } from '@manacore/shared-error-tracking/frontend'; + * import { errorTracker } from '$lib/error-tracking'; + * + * if (typeof window !== 'undefined') { + * setupGlobalErrorHandler(errorTracker); + * } + * ``` + */ +export function setupGlobalErrorHandler(errorTracker: ErrorTracker): void { + if (typeof window === 'undefined') { + return; + } + + // Handle unhandled errors + window.addEventListener('error', (event) => { + void errorTracker.captureError(event.error || new Error(event.message), { + type: 'unhandled_error', + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + }); + + // Handle unhandled promise rejections + window.addEventListener('unhandledrejection', (event) => { + void errorTracker.captureError(event.reason || new Error('Unhandled promise rejection'), { + type: 'unhandled_rejection', + }); + }); +} diff --git a/packages/shared-error-tracking/src/index.ts b/packages/shared-error-tracking/src/index.ts new file mode 100644 index 000000000..a540d9199 --- /dev/null +++ b/packages/shared-error-tracking/src/index.ts @@ -0,0 +1,4 @@ +// Re-export everything for convenience +export * from './types'; +export * from './nestjs'; +export * from './frontend'; diff --git a/packages/shared-error-tracking/src/nestjs/error-tracking.filter.ts b/packages/shared-error-tracking/src/nestjs/error-tracking.filter.ts new file mode 100644 index 000000000..319834136 --- /dev/null +++ b/packages/shared-error-tracking/src/nestjs/error-tracking.filter.ts @@ -0,0 +1,118 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Injectable, + Logger, +} from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { ErrorTrackingService } from './error-tracking.service'; + +// Sensitive header keys to sanitize before logging +const SENSITIVE_HEADERS = ['authorization', 'cookie', 'x-api-key', 'api-key']; + +// Sensitive body field keys to sanitize +const SENSITIVE_BODY_FIELDS = ['password', 'token', 'secret', 'apikey', 'api_key']; + +@Injectable() +@Catch() +export class ErrorTrackingFilter implements ExceptionFilter { + private readonly logger = new Logger(ErrorTrackingFilter.name); + + constructor( + private readonly httpAdapterHost: HttpAdapterHost, + private readonly errorTrackingService: ErrorTrackingService + ) {} + + catch(exception: unknown, host: ArgumentsHost): void { + const { httpAdapter } = this.httpAdapterHost; + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + // Determine status code + const httpStatus = + exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + // Build error message + const message = + exception instanceof Error + ? exception.message + : typeof exception === 'string' + ? exception + : 'Internal server error'; + + // Build response body + const responseBody = { + statusCode: httpStatus, + timestamp: new Date().toISOString(), + path: httpAdapter.getRequestUrl(request), + message, + }; + + // Report error to tracking service (fire and forget) + this.trackError(exception, request, httpStatus).catch((err) => { + this.logger.warn('Failed to track error', err); + }); + + // Send response + httpAdapter.reply(response, responseBody, httpStatus); + } + + private async trackError( + exception: unknown, + request: Record, + statusCode: number + ): Promise { + // Don't track 4xx client errors below 500 by default (optional) + // You can customize this based on your needs + const error = exception instanceof Error ? exception : new Error(String(exception)); + + const sanitizedHeaders = this.sanitizeHeaders(request.headers as Record); + const sanitizedBody = this.sanitizeBody(request.body as Record); + + await this.errorTrackingService.reportHttpException( + error, + { + url: request.url as string, + method: request.method as string, + headers: sanitizedHeaders, + body: sanitizedBody, + user: request.user as { userId?: string; sessionId?: string }, + }, + statusCode + ); + } + + private sanitizeHeaders(headers?: Record): Record | undefined { + if (!headers) return undefined; + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (SENSITIVE_HEADERS.includes(key.toLowerCase())) { + sanitized[key] = '[REDACTED]'; + } else { + sanitized[key] = value; + } + } + return sanitized; + } + + private sanitizeBody(body?: Record): Record | undefined { + if (!body) return undefined; + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (SENSITIVE_BODY_FIELDS.includes(key.toLowerCase())) { + sanitized[key] = '[REDACTED]'; + } else if (typeof value === 'object' && value !== null) { + sanitized[key] = this.sanitizeBody(value as Record); + } else { + sanitized[key] = value; + } + } + return sanitized; + } +} diff --git a/packages/shared-error-tracking/src/nestjs/error-tracking.module.ts b/packages/shared-error-tracking/src/nestjs/error-tracking.module.ts new file mode 100644 index 000000000..eb3a61125 --- /dev/null +++ b/packages/shared-error-tracking/src/nestjs/error-tracking.module.ts @@ -0,0 +1,58 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import type { + InjectionToken, + OptionalFactoryDependency, + Type, + ForwardReference, +} from '@nestjs/common'; +import { ErrorTrackingService, ERROR_TRACKING_CONFIG } from './error-tracking.service'; +import type { ErrorTrackingConfig } from '../types'; + +export type ErrorTrackingModuleOptions = ErrorTrackingConfig; + +export interface ErrorTrackingModuleAsyncOptions { + useFactory: (...args: unknown[]) => Promise | ErrorTrackingConfig; + inject?: (InjectionToken | OptionalFactoryDependency)[]; + imports?: (Type | DynamicModule | Promise | ForwardReference)[]; +} + +@Module({}) +export class ErrorTrackingModule { + /** + * Register the error tracking module with static configuration + */ + static forRoot(options: ErrorTrackingModuleOptions): DynamicModule { + return { + module: ErrorTrackingModule, + providers: [ + { + provide: ERROR_TRACKING_CONFIG, + useValue: options, + }, + ErrorTrackingService, + ], + exports: [ErrorTrackingService], + global: true, + }; + } + + /** + * Register the error tracking module with async configuration + */ + static forRootAsync(options: ErrorTrackingModuleAsyncOptions): DynamicModule { + return { + module: ErrorTrackingModule, + imports: options.imports, + providers: [ + { + provide: ERROR_TRACKING_CONFIG, + useFactory: options.useFactory, + inject: options.inject, + }, + ErrorTrackingService, + ], + exports: [ErrorTrackingService], + global: true, + }; + } +} diff --git a/packages/shared-error-tracking/src/nestjs/error-tracking.service.ts b/packages/shared-error-tracking/src/nestjs/error-tracking.service.ts new file mode 100644 index 000000000..5b8de07af --- /dev/null +++ b/packages/shared-error-tracking/src/nestjs/error-tracking.service.ts @@ -0,0 +1,194 @@ +import { Injectable, Logger, Inject, Optional } from '@nestjs/common'; +import type { + ErrorTrackingConfig, + ErrorLogPayload, + ReportErrorOptions, + CreateErrorLogResponse, +} from '../types'; + +export const ERROR_TRACKING_CONFIG = 'ERROR_TRACKING_CONFIG'; + +@Injectable() +export class ErrorTrackingService { + private readonly logger = new Logger(ErrorTrackingService.name); + private readonly config: ErrorTrackingConfig; + + constructor( + @Inject(ERROR_TRACKING_CONFIG) + @Optional() + config?: ErrorTrackingConfig + ) { + this.config = config || { + errorTrackingUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + appId: 'unknown', + }; + } + + /** + * Report an error to the error tracking service + */ + async reportError(options: ReportErrorOptions): Promise { + const payload: ErrorLogPayload = { + errorCode: options.errorCode, + errorType: options.errorType, + message: options.message, + severity: options.severity || 'error', + context: options.context, + stackTrace: options.stackTrace, + appId: this.config.appId, + serviceName: this.config.serviceName, + sourceType: 'backend', + environment: this.config.environment || this.detectEnvironment(), + occurredAt: new Date().toISOString(), + }; + + return this.sendErrorLog(payload); + } + + /** + * Report an exception (Error object) to the error tracking service + */ + async reportException( + error: Error, + context?: Record + ): Promise { + const payload: ErrorLogPayload = { + errorCode: this.extractErrorCode(error), + errorType: error.constructor.name, + message: error.message, + stackTrace: error.stack, + severity: 'error', + context, + appId: this.config.appId, + serviceName: this.config.serviceName, + sourceType: 'backend', + environment: this.config.environment || this.detectEnvironment(), + occurredAt: new Date().toISOString(), + }; + + return this.sendErrorLog(payload); + } + + /** + * Report an HTTP exception with request details + */ + async reportHttpException( + error: Error, + request: { + url?: string; + method?: string; + headers?: Record; + body?: Record; + user?: { userId?: string; sessionId?: string }; + }, + statusCode?: number + ): Promise { + const payload: ErrorLogPayload = { + errorCode: this.extractErrorCode(error), + errorType: error.constructor.name, + message: error.message, + stackTrace: error.stack, + severity: this.getSeverityFromStatusCode(statusCode), + appId: this.config.appId, + serviceName: this.config.serviceName, + sourceType: 'backend', + environment: this.config.environment || this.detectEnvironment(), + requestUrl: request.url, + requestMethod: request.method, + requestHeaders: request.headers, + requestBody: request.body, + responseStatusCode: statusCode, + userId: request.user?.userId, + sessionId: request.user?.sessionId, + occurredAt: new Date().toISOString(), + }; + + return this.sendErrorLog(payload); + } + + /** + * Send error log to the tracking endpoint + */ + private async sendErrorLog(payload: ErrorLogPayload): Promise { + // Log locally if enabled + if (this.config.enableLocalLogging !== false) { + this.logger.error(`[${payload.errorCode}] ${payload.message}`, payload.stackTrace); + } + + // Skip sending to remote in development by default + if (this.detectEnvironment() === 'development' && !this.config.errorTrackingUrl) { + return { success: true, id: 'local-only' }; + } + + try { + const url = `${this.config.errorTrackingUrl}/api/v1/errors`; + const headers: Record = { + 'Content-Type': 'application/json', + 'X-App-Id': this.config.appId, + ...this.config.customHeaders, + }; + + // Add auth token if available + if (this.config.getAuthToken) { + const token = await this.config.getAuthToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + this.logger.warn(`Failed to send error log: HTTP ${response.status}`); + return { success: false, error: `HTTP ${response.status}` }; + } + + const result = (await response.json()) as CreateErrorLogResponse; + return result; + } catch (err) { + this.logger.warn('Failed to send error log to tracking service', err); + return { success: false, error: 'Network error' }; + } + } + + /** + * Detect current environment + */ + private detectEnvironment(): 'development' | 'staging' | 'production' { + const nodeEnv = process.env.NODE_ENV; + if (nodeEnv === 'production') return 'production'; + if (nodeEnv === 'staging') return 'staging'; + return 'development'; + } + + /** + * Extract error code from error object + */ + private extractErrorCode(error: Error): string { + // Check for common NestJS exception properties + const anyError = error as unknown as Record; + if (anyError.code && typeof anyError.code === 'string') { + return anyError.code; + } + if (anyError.name && typeof anyError.name === 'string') { + return anyError.name; + } + return 'UNKNOWN_ERROR'; + } + + /** + * Get severity level from HTTP status code + */ + private getSeverityFromStatusCode( + statusCode?: number + ): 'debug' | 'info' | 'warning' | 'error' | 'critical' { + if (!statusCode) return 'error'; + if (statusCode >= 500) return 'critical'; + if (statusCode >= 400) return 'warning'; + return 'info'; + } +} diff --git a/packages/shared-error-tracking/src/nestjs/index.ts b/packages/shared-error-tracking/src/nestjs/index.ts new file mode 100644 index 000000000..6f8d6990e --- /dev/null +++ b/packages/shared-error-tracking/src/nestjs/index.ts @@ -0,0 +1,7 @@ +export { ErrorTrackingModule } from './error-tracking.module'; +export type { + ErrorTrackingModuleOptions, + ErrorTrackingModuleAsyncOptions, +} from './error-tracking.module'; +export { ErrorTrackingService, ERROR_TRACKING_CONFIG } from './error-tracking.service'; +export { ErrorTrackingFilter } from './error-tracking.filter'; diff --git a/packages/shared-error-tracking/src/types/index.ts b/packages/shared-error-tracking/src/types/index.ts new file mode 100644 index 000000000..6ee4c89c8 --- /dev/null +++ b/packages/shared-error-tracking/src/types/index.ts @@ -0,0 +1,111 @@ +/** + * Error tracking configuration options + */ +export interface ErrorTrackingConfig { + /** URL of mana-core-auth service */ + errorTrackingUrl: string; + + /** App identifier (e.g., 'chat', 'picture') */ + appId: string; + + /** Service name for identification */ + serviceName?: string; + + /** Default environment if not detected */ + environment?: 'development' | 'staging' | 'production'; + + /** Log errors locally as well (default: true in dev) */ + enableLocalLogging?: boolean; + + /** Custom headers for requests */ + customHeaders?: Record; + + /** Function to get auth token (optional) */ + getAuthToken?: () => Promise; +} + +/** + * Error source types + */ +export type ErrorSourceType = 'backend' | 'frontend_web' | 'frontend_mobile'; + +/** + * Error environments + */ +export type ErrorEnvironment = 'development' | 'staging' | 'production'; + +/** + * Error severity levels + */ +export type ErrorSeverity = 'debug' | 'info' | 'warning' | 'error' | 'critical'; + +/** + * Error log payload sent to the API + */ +export interface ErrorLogPayload { + // Required + errorCode: string; + errorType: string; + message: string; + + // Optional + stackTrace?: string; + appId?: string; + sourceType?: ErrorSourceType; + serviceName?: string; + userId?: string; + sessionId?: string; + requestUrl?: string; + requestMethod?: string; + requestHeaders?: Record; + requestBody?: Record; + responseStatusCode?: number; + environment?: ErrorEnvironment; + severity?: ErrorSeverity; + context?: Record; + fingerprint?: string; + browserInfo?: Record; + deviceInfo?: Record; + userAgent?: string; + occurredAt?: string; +} + +/** + * Response from creating a single error log + */ +export interface CreateErrorLogResponse { + success: boolean; + id?: string; + error?: string; +} + +/** + * Response from creating batch error logs + */ +export interface BatchErrorLogResponse { + success: boolean; + total: number; + succeeded: number; + failed: number; +} + +/** + * Manual error report options + */ +export interface ReportErrorOptions { + errorCode: string; + errorType: string; + message: string; + severity?: ErrorSeverity; + context?: Record; + stackTrace?: string; +} + +/** + * Context for error capture in frontends + */ +export interface ErrorContext { + component?: string; + action?: string; + [key: string]: unknown; +} diff --git a/packages/shared-error-tracking/tsconfig.json b/packages/shared-error-tracking/tsconfig.json new file mode 100644 index 000000000..71e4820d5 --- /dev/null +++ b/packages/shared-error-tracking/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}