feat(auth): add error logs API and database schema

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
This commit is contained in:
Wuesteon 2025-12-19 02:17:55 +01:00
parent 5e1118b711
commit 319ccd1a46
11 changed files with 527 additions and 0 deletions

View file

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

View file

@ -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");

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>;
@IsObject()
@IsOptional()
requestBody?: Record<string, unknown>;
@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<string, unknown>;
@IsString()
@IsOptional()
@MaxLength(256)
fingerprint?: string;
@IsObject()
@IsOptional()
browserInfo?: Record<string, unknown>;
@IsObject()
@IsOptional()
deviceInfo?: Record<string, unknown>;
@IsString()
@IsOptional()
@MaxLength(500)
userAgent?: string;
@IsISO8601()
@IsOptional()
occurredAt?: string;
}

View file

@ -0,0 +1,2 @@
export * from './create-error-log.dto';
export * from './batch-error-log.dto';

View file

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

View file

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

View file

@ -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<string>('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<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;
}
/**
* Sanitize body to remove sensitive information
*/
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;
}
/**
* 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;
}
}
}