From 319ccd1a467b837b02f3f46ef16eaeeee51e021a Mon Sep 17 00:00:00 2001 From: Wuesteon Date: Fri, 19 Dec 2025 02:17:55 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(auth):=20add=20error=20logs=20?= =?UTF-8?q?API=20and=20database=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add centralized error logging endpoint to mana-core-auth: - Error logs database schema with app_id, error message, stack traces - POST /error-logs endpoint for single errors - POST /error-logs/batch endpoint for batch submissions - Error logs service with automatic cleanup of old entries - DTOs with validation for error log submissions --- services/mana-core-auth/src/app.module.ts | 2 + .../src/db/migrations/0003_add_error_logs.sql | 71 ++++++++ .../src/db/migrations/meta/_journal.json | 7 + .../src/db/schema/error-logs.schema.ts | 97 ++++++++++ .../mana-core-auth/src/db/schema/index.ts | 1 + .../src/error-logs/dto/batch-error-log.dto.ts | 12 ++ .../error-logs/dto/create-error-log.dto.ts | 114 ++++++++++++ .../src/error-logs/dto/index.ts | 2 + .../src/error-logs/error-logs.controller.ts | 39 ++++ .../src/error-logs/error-logs.module.ts | 11 ++ .../src/error-logs/error-logs.service.ts | 171 ++++++++++++++++++ 11 files changed, 527 insertions(+) create mode 100644 services/mana-core-auth/src/db/migrations/0003_add_error_logs.sql create mode 100644 services/mana-core-auth/src/db/schema/error-logs.schema.ts create mode 100644 services/mana-core-auth/src/error-logs/dto/batch-error-log.dto.ts create mode 100644 services/mana-core-auth/src/error-logs/dto/create-error-log.dto.ts create mode 100644 services/mana-core-auth/src/error-logs/dto/index.ts create mode 100644 services/mana-core-auth/src/error-logs/error-logs.controller.ts create mode 100644 services/mana-core-auth/src/error-logs/error-logs.module.ts create mode 100644 services/mana-core-auth/src/error-logs/error-logs.service.ts diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 0f208997a..1d1853fe2 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -6,6 +6,7 @@ import configuration from './config/configuration'; import { AuthModule } from './auth/auth.module'; import { CreditsModule } from './credits/credits.module'; import { EmailModule } from './email/email.module'; +import { ErrorLogsModule } from './error-logs/error-logs.module'; import { FeedbackModule } from './feedback/feedback.module'; import { ReferralsModule } from './referrals/referrals.module'; import { SecurityModule } from './security/security.module'; @@ -31,6 +32,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; AuthModule, CreditsModule, EmailModule, + ErrorLogsModule, FeedbackModule, HealthModule, ReferralsModule, diff --git a/services/mana-core-auth/src/db/migrations/0003_add_error_logs.sql b/services/mana-core-auth/src/db/migrations/0003_add_error_logs.sql new file mode 100644 index 000000000..3cd1def62 --- /dev/null +++ b/services/mana-core-auth/src/db/migrations/0003_add_error_logs.sql @@ -0,0 +1,71 @@ +-- Add error_logs schema and table for centralized error tracking +-- This migration is safe to run on existing databases + +-- Create error_logs schema if not exists +CREATE SCHEMA IF NOT EXISTS "error_logs"; + +-- Create enum types if not exist (PostgreSQL 9.1+ required for IF NOT EXISTS) +DO $$ BEGIN + CREATE TYPE "error_source_type" AS ENUM('backend', 'frontend_web', 'frontend_mobile'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE "error_environment" AS ENUM('development', 'staging', 'production'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE "error_severity" AS ENUM('debug', 'info', 'warning', 'error', 'critical'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create error_logs table +CREATE TABLE IF NOT EXISTS "error_logs"."error_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "error_code" text NOT NULL, + "error_type" text NOT NULL, + "message" text NOT NULL, + "stack_trace" text, + "app_id" text NOT NULL, + "source_type" "error_source_type", + "service_name" text, + "user_id" text, + "session_id" text, + "request_url" text, + "request_method" text, + "request_headers" jsonb, + "request_body" jsonb, + "response_status_code" integer, + "environment" "error_environment", + "severity" "error_severity" DEFAULT 'error', + "context" jsonb DEFAULT '{}'::jsonb, + "fingerprint" text, + "user_agent" text, + "browser_info" jsonb, + "device_info" jsonb, + "occurred_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +-- Add foreign key constraint (safe - ignores if exists) +DO $$ BEGIN + ALTER TABLE "error_logs"."error_logs" + ADD CONSTRAINT "error_logs_user_id_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") + ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create indexes (safe - ignores if exists) +CREATE INDEX IF NOT EXISTS "error_logs_app_id_idx" ON "error_logs"."error_logs" USING btree ("app_id"); +CREATE INDEX IF NOT EXISTS "error_logs_user_id_idx" ON "error_logs"."error_logs" USING btree ("user_id"); +CREATE INDEX IF NOT EXISTS "error_logs_environment_idx" ON "error_logs"."error_logs" USING btree ("environment"); +CREATE INDEX IF NOT EXISTS "error_logs_severity_idx" ON "error_logs"."error_logs" USING btree ("severity"); +CREATE INDEX IF NOT EXISTS "error_logs_occurred_at_idx" ON "error_logs"."error_logs" USING btree ("occurred_at"); +CREATE INDEX IF NOT EXISTS "error_logs_error_code_idx" ON "error_logs"."error_logs" USING btree ("error_code"); +CREATE INDEX IF NOT EXISTS "error_logs_fingerprint_idx" ON "error_logs"."error_logs" USING btree ("fingerprint"); diff --git a/services/mana-core-auth/src/db/migrations/meta/_journal.json b/services/mana-core-auth/src/db/migrations/meta/_journal.json index 03344b8be..1be99323b 100644 --- a/services/mana-core-auth/src/db/migrations/meta/_journal.json +++ b/services/mana-core-auth/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1734560000000, "tag": "0002_fix_session_columns", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1734600000000, + "tag": "0003_add_error_logs", + "breakpoints": true } ] } diff --git a/services/mana-core-auth/src/db/schema/error-logs.schema.ts b/services/mana-core-auth/src/db/schema/error-logs.schema.ts new file mode 100644 index 000000000..3be54b0a4 --- /dev/null +++ b/services/mana-core-auth/src/db/schema/error-logs.schema.ts @@ -0,0 +1,97 @@ +import { + pgSchema, + uuid, + text, + timestamp, + jsonb, + integer, + index, + pgEnum, +} from 'drizzle-orm/pg-core'; +import { users } from './auth.schema'; + +export const errorLogsSchema = pgSchema('error_logs'); + +// Source type enum +export const errorSourceTypeEnum = pgEnum('error_source_type', [ + 'backend', + 'frontend_web', + 'frontend_mobile', +]); + +// Environment enum +export const errorEnvironmentEnum = pgEnum('error_environment', [ + 'development', + 'staging', + 'production', +]); + +// Severity enum +export const errorSeverityEnum = pgEnum('error_severity', [ + 'debug', + 'info', + 'warning', + 'error', + 'critical', +]); + +// Error logs table +export const errorLogs = errorLogsSchema.table( + 'error_logs', + { + // Primary key + id: uuid('id').primaryKey().defaultRandom(), + + // Error identification + errorCode: text('error_code').notNull(), + errorType: text('error_type').notNull(), + message: text('message').notNull(), + stackTrace: text('stack_trace'), + + // Source identification + appId: text('app_id').notNull(), + sourceType: errorSourceTypeEnum('source_type'), + serviceName: text('service_name'), + + // User context (optional) + userId: text('user_id').references(() => users.id, { onDelete: 'set null' }), + sessionId: text('session_id'), + + // Request metadata (backend errors) + requestUrl: text('request_url'), + requestMethod: text('request_method'), + requestHeaders: jsonb('request_headers'), + requestBody: jsonb('request_body'), + responseStatusCode: integer('response_status_code'), + + // Classification + environment: errorEnvironmentEnum('environment'), + severity: errorSeverityEnum('severity').default('error'), + + // Additional context + context: jsonb('context').default({}), + fingerprint: text('fingerprint'), + + // Browser/device info (frontend errors) + userAgent: text('user_agent'), + browserInfo: jsonb('browser_info'), + deviceInfo: jsonb('device_info'), + + // Timestamps + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + appIdIdx: index('error_logs_app_id_idx').on(table.appId), + userIdIdx: index('error_logs_user_id_idx').on(table.userId), + environmentIdx: index('error_logs_environment_idx').on(table.environment), + severityIdx: index('error_logs_severity_idx').on(table.severity), + occurredAtIdx: index('error_logs_occurred_at_idx').on(table.occurredAt), + errorCodeIdx: index('error_logs_error_code_idx').on(table.errorCode), + fingerprintIdx: index('error_logs_fingerprint_idx').on(table.fingerprint), + }) +); + +// Type exports +export type ErrorLog = typeof errorLogs.$inferSelect; +export type NewErrorLog = typeof errorLogs.$inferInsert; diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index 72a7970f2..349bbba53 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -1,5 +1,6 @@ export * from './auth.schema'; export * from './credits.schema'; +export * from './error-logs.schema'; export * from './feedback.schema'; export * from './organizations.schema'; export * from './referrals.schema'; diff --git a/services/mana-core-auth/src/error-logs/dto/batch-error-log.dto.ts b/services/mana-core-auth/src/error-logs/dto/batch-error-log.dto.ts new file mode 100644 index 000000000..bb23d3eac --- /dev/null +++ b/services/mana-core-auth/src/error-logs/dto/batch-error-log.dto.ts @@ -0,0 +1,12 @@ +import { Type } from 'class-transformer'; +import { ValidateNested, IsArray, ArrayMaxSize, ArrayMinSize } from 'class-validator'; +import { CreateErrorLogDto } from './create-error-log.dto'; + +export class BatchErrorLogDto { + @IsArray() + @ValidateNested({ each: true }) + @ArrayMinSize(1) + @ArrayMaxSize(100) + @Type(() => CreateErrorLogDto) + errors: CreateErrorLogDto[]; +} diff --git a/services/mana-core-auth/src/error-logs/dto/create-error-log.dto.ts b/services/mana-core-auth/src/error-logs/dto/create-error-log.dto.ts new file mode 100644 index 000000000..3288dad7b --- /dev/null +++ b/services/mana-core-auth/src/error-logs/dto/create-error-log.dto.ts @@ -0,0 +1,114 @@ +import { + IsString, + IsOptional, + MaxLength, + IsEnum, + IsObject, + IsInt, + IsISO8601, + Min, + Max, +} from 'class-validator'; + +export class CreateErrorLogDto { + // Required fields + @IsString() + @MaxLength(100) + errorCode: string; + + @IsString() + @MaxLength(100) + errorType: string; + + @IsString() + @MaxLength(5000) + message: string; + + // Optional fields + @IsString() + @IsOptional() + @MaxLength(50000) + stackTrace?: string; + + @IsString() + @IsOptional() + @MaxLength(50) + appId?: string; + + @IsEnum(['backend', 'frontend_web', 'frontend_mobile']) + @IsOptional() + sourceType?: 'backend' | 'frontend_web' | 'frontend_mobile'; + + @IsString() + @IsOptional() + @MaxLength(100) + serviceName?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + userId?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + sessionId?: string; + + @IsString() + @IsOptional() + @MaxLength(2000) + requestUrl?: string; + + @IsString() + @IsOptional() + @MaxLength(10) + requestMethod?: string; + + @IsObject() + @IsOptional() + requestHeaders?: Record; + + @IsObject() + @IsOptional() + requestBody?: Record; + + @IsInt() + @IsOptional() + @Min(100) + @Max(599) + responseStatusCode?: number; + + @IsEnum(['development', 'staging', 'production']) + @IsOptional() + environment?: 'development' | 'staging' | 'production'; + + @IsEnum(['debug', 'info', 'warning', 'error', 'critical']) + @IsOptional() + severity?: 'debug' | 'info' | 'warning' | 'error' | 'critical'; + + @IsObject() + @IsOptional() + context?: Record; + + @IsString() + @IsOptional() + @MaxLength(256) + fingerprint?: string; + + @IsObject() + @IsOptional() + browserInfo?: Record; + + @IsObject() + @IsOptional() + deviceInfo?: Record; + + @IsString() + @IsOptional() + @MaxLength(500) + userAgent?: string; + + @IsISO8601() + @IsOptional() + occurredAt?: string; +} diff --git a/services/mana-core-auth/src/error-logs/dto/index.ts b/services/mana-core-auth/src/error-logs/dto/index.ts new file mode 100644 index 000000000..9abfcd9ce --- /dev/null +++ b/services/mana-core-auth/src/error-logs/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-error-log.dto'; +export * from './batch-error-log.dto'; diff --git a/services/mana-core-auth/src/error-logs/error-logs.controller.ts b/services/mana-core-auth/src/error-logs/error-logs.controller.ts new file mode 100644 index 000000000..81882ec05 --- /dev/null +++ b/services/mana-core-auth/src/error-logs/error-logs.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Post, Body, Headers, UseGuards } from '@nestjs/common'; +import { ErrorLogsService } from './error-logs.service'; +import { OptionalAuthGuard } from '../common/guards/optional-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { CurrentUserData } from '../common/decorators/current-user.decorator'; +import { CreateErrorLogDto, BatchErrorLogDto } from './dto'; + +@Controller('api/v1/errors') +export class ErrorLogsController { + constructor(private readonly errorLogsService: ErrorLogsService) {} + + /** + * Create a single error log entry + * Authentication is optional - uses user context if available + */ + @Post() + @UseGuards(OptionalAuthGuard) + async createErrorLog( + @CurrentUser() user: CurrentUserData | null, + @Body() dto: CreateErrorLogDto, + @Headers('x-app-id') appIdHeader?: string + ) { + return this.errorLogsService.createErrorLog(dto, appIdHeader, user?.userId); + } + + /** + * Create multiple error log entries in batch + * Useful for batch reporting of errors (e.g., on app startup or periodic sync) + */ + @Post('batch') + @UseGuards(OptionalAuthGuard) + async createErrorLogsBatch( + @CurrentUser() user: CurrentUserData | null, + @Body() dto: BatchErrorLogDto, + @Headers('x-app-id') appIdHeader?: string + ) { + return this.errorLogsService.createErrorLogsBatch(dto.errors, appIdHeader, user?.userId); + } +} diff --git a/services/mana-core-auth/src/error-logs/error-logs.module.ts b/services/mana-core-auth/src/error-logs/error-logs.module.ts new file mode 100644 index 000000000..96ee40674 --- /dev/null +++ b/services/mana-core-auth/src/error-logs/error-logs.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ErrorLogsController } from './error-logs.controller'; +import { ErrorLogsService } from './error-logs.service'; +import { OptionalAuthGuard } from '../common/guards/optional-auth.guard'; + +@Module({ + controllers: [ErrorLogsController], + providers: [ErrorLogsService, OptionalAuthGuard], + exports: [ErrorLogsService], +}) +export class ErrorLogsModule {} diff --git a/services/mana-core-auth/src/error-logs/error-logs.service.ts b/services/mana-core-auth/src/error-logs/error-logs.service.ts new file mode 100644 index 000000000..a05d266e8 --- /dev/null +++ b/services/mana-core-auth/src/error-logs/error-logs.service.ts @@ -0,0 +1,171 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb } from '../db/connection'; +import { errorLogs } from '../db/schema'; +import type { CreateErrorLogDto } from './dto'; +import * as crypto from 'crypto'; + +// Sensitive header keys to sanitize +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() +export class ErrorLogsService { + private readonly logger = new Logger(ErrorLogsService.name); + + constructor(private configService: ConfigService) {} + + private getDb() { + const databaseUrl = this.configService.get('database.url'); + return getDb(databaseUrl!); + } + + /** + * Create a single error log entry + */ + async createErrorLog( + dto: CreateErrorLogDto, + appIdHeader?: string, + userId?: string + ): Promise<{ success: boolean; id?: string; error?: string }> { + try { + const db = this.getDb(); + + const appId = dto.appId || appIdHeader || 'unknown'; + const sanitizedHeaders = this.sanitizeHeaders(dto.requestHeaders); + const sanitizedBody = this.sanitizeBody(dto.requestBody); + const fingerprint = dto.fingerprint || this.generateFingerprint(dto, appId); + const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); + + const [errorLog] = await db + .insert(errorLogs) + .values({ + errorCode: dto.errorCode, + errorType: dto.errorType, + message: dto.message, + stackTrace: dto.stackTrace, + appId, + sourceType: dto.sourceType, + serviceName: dto.serviceName, + userId: dto.userId || userId, + sessionId: dto.sessionId, + requestUrl: dto.requestUrl, + requestMethod: dto.requestMethod, + requestHeaders: sanitizedHeaders, + requestBody: sanitizedBody, + responseStatusCode: dto.responseStatusCode, + environment: dto.environment, + severity: dto.severity || 'error', + context: dto.context || {}, + fingerprint, + userAgent: dto.userAgent, + browserInfo: dto.browserInfo, + deviceInfo: dto.deviceInfo, + occurredAt, + }) + .returning({ id: errorLogs.id }); + + return { success: true, id: errorLog.id }; + } catch (error) { + this.logger.error('Failed to create error log', error); + return { success: false, error: 'Failed to create error log' }; + } + } + + /** + * Create multiple error log entries in batch + */ + async createErrorLogsBatch( + errors: CreateErrorLogDto[], + appIdHeader?: string, + userId?: string + ): Promise<{ success: boolean; total: number; succeeded: number; failed: number }> { + let succeeded = 0; + let failed = 0; + + for (const errorDto of errors) { + const result = await this.createErrorLog(errorDto, appIdHeader, userId); + if (result.success) { + succeeded++; + } else { + failed++; + } + } + + return { + success: failed === 0, + total: errors.length, + succeeded, + failed, + }; + } + + /** + * Sanitize headers to remove sensitive information + */ + 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; + } + + /** + * Sanitize body to remove sensitive information + */ + 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; + } + + /** + * Generate a fingerprint for error grouping/deduplication + */ + private generateFingerprint(dto: CreateErrorLogDto, appId: string): string { + const parts = [ + dto.errorCode, + dto.errorType, + appId, + dto.requestMethod || '', + this.extractPathFromUrl(dto.requestUrl), + ]; + + const hash = crypto.createHash('sha256').update(parts.join('|')).digest('hex'); + return hash.substring(0, 32); + } + + /** + * Extract path from URL (without query parameters) + */ + private extractPathFromUrl(url?: string): string { + if (!url) return ''; + try { + const parsed = new URL(url, 'http://placeholder'); + return parsed.pathname; + } catch { + // If URL parsing fails, try to extract path manually + const queryStart = url.indexOf('?'); + return queryStart > -1 ? url.substring(0, queryStart) : url; + } + } +}