mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
✨ feat(error-tracking): add shared error tracking package
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
This commit is contained in:
parent
f834986a82
commit
5e1118b711
12 changed files with 1033 additions and 0 deletions
67
packages/shared-error-tracking/package.json
Normal file
67
packages/shared-error-tracking/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
257
packages/shared-error-tracking/src/frontend/error-tracker.ts
Normal file
257
packages/shared-error-tracking/src/frontend/error-tracker.ts
Normal file
|
|
@ -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<CreateErrorLogResponse> {
|
||||
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<CreateErrorLogResponse> {
|
||||
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<void> {
|
||||
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<CreateErrorLogResponse> {
|
||||
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<void> {
|
||||
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<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
'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<string, unknown>;
|
||||
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<string, unknown> | 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);
|
||||
}
|
||||
107
packages/shared-error-tracking/src/frontend/expo.ts
Normal file
107
packages/shared-error-tracking/src/frontend/expo.ts
Normal file
|
|
@ -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 (
|
||||
* <ErrorBoundary onError={errorHandler} FallbackComponent={ErrorFallback}>
|
||||
* <RootNavigator />
|
||||
* </ErrorBoundary>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
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<string, unknown> {
|
||||
const info: Record<string, unknown> = {
|
||||
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;
|
||||
};
|
||||
7
packages/shared-error-tracking/src/frontend/index.ts
Normal file
7
packages/shared-error-tracking/src/frontend/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { ErrorTracker, createErrorTracker } from './error-tracker';
|
||||
export { createSvelteErrorHandler, setupGlobalErrorHandler } from './sveltekit';
|
||||
export {
|
||||
createExpoErrorHandler,
|
||||
getReactNativeDeviceInfo,
|
||||
setupReactNativeErrorHandler,
|
||||
} from './expo';
|
||||
79
packages/shared-error-tracking/src/frontend/sveltekit.ts
Normal file
79
packages/shared-error-tracking/src/frontend/sveltekit.ts
Normal file
|
|
@ -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<string, string>; 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',
|
||||
});
|
||||
});
|
||||
}
|
||||
4
packages/shared-error-tracking/src/index.ts
Normal file
4
packages/shared-error-tracking/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Re-export everything for convenience
|
||||
export * from './types';
|
||||
export * from './nestjs';
|
||||
export * from './frontend';
|
||||
|
|
@ -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<string, unknown>,
|
||||
statusCode: number
|
||||
): Promise<void> {
|
||||
// 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<string, unknown>);
|
||||
const sanitizedBody = this.sanitizeBody(request.body as Record<string, unknown>);
|
||||
|
||||
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<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!headers) return undefined;
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
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<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!body) return undefined;
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
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<string, unknown>);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
|
@ -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> | ErrorTrackingConfig;
|
||||
inject?: (InjectionToken | OptionalFactoryDependency)[];
|
||||
imports?: (Type | DynamicModule | Promise<DynamicModule> | 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CreateErrorLogResponse> {
|
||||
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<string, unknown>
|
||||
): Promise<CreateErrorLogResponse> {
|
||||
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<string, unknown>;
|
||||
body?: Record<string, unknown>;
|
||||
user?: { userId?: string; sessionId?: string };
|
||||
},
|
||||
statusCode?: number
|
||||
): Promise<CreateErrorLogResponse> {
|
||||
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<CreateErrorLogResponse> {
|
||||
// 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<string, string> = {
|
||||
'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<string, unknown>;
|
||||
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';
|
||||
}
|
||||
}
|
||||
7
packages/shared-error-tracking/src/nestjs/index.ts
Normal file
7
packages/shared-error-tracking/src/nestjs/index.ts
Normal file
|
|
@ -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';
|
||||
111
packages/shared-error-tracking/src/types/index.ts
Normal file
111
packages/shared-error-tracking/src/types/index.ts
Normal file
|
|
@ -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<string, string>;
|
||||
|
||||
/** Function to get auth token (optional) */
|
||||
getAuthToken?: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
requestBody?: Record<string, unknown>;
|
||||
responseStatusCode?: number;
|
||||
environment?: ErrorEnvironment;
|
||||
severity?: ErrorSeverity;
|
||||
context?: Record<string, unknown>;
|
||||
fingerprint?: string;
|
||||
browserInfo?: Record<string, unknown>;
|
||||
deviceInfo?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
stackTrace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for error capture in frontends
|
||||
*/
|
||||
export interface ErrorContext {
|
||||
component?: string;
|
||||
action?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
24
packages/shared-error-tracking/tsconfig.json
Normal file
24
packages/shared-error-tracking/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue