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:
Wuesteon 2025-12-19 02:17:36 +01:00
parent f834986a82
commit 5e1118b711
12 changed files with 1033 additions and 0 deletions

View file

@ -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;
}
}

View file

@ -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,
};
}
}

View file

@ -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';
}
}

View 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';