mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-27 04:37:43 +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
|
|
@ -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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue