diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js new file mode 100644 index 0000000..a8f82cb --- /dev/null +++ b/apps/api/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testRegex: '.*\\.spec\\.ts$', +}; diff --git a/apps/api/src/account/account.controller.ts b/apps/api/src/account/account.controller.ts index 6f493e2..035b407 100644 --- a/apps/api/src/account/account.controller.ts +++ b/apps/api/src/account/account.controller.ts @@ -4,8 +4,12 @@ import { Request } from 'express'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Repository } from 'typeorm'; import { AuditService } from '../audit/audit.service'; +import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; +import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; +import { maskEmail } from '../application-error-log/application-error-sanitizer'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RequestUser } from '../common/request-user'; +import { RequestContextService } from '../common/request-context.service'; import { hashToken, randomToken } from '../common/token.util'; import { LldapService } from '../lldap/lldap.service'; import { PortalMailService } from '../mail/portal-mail.service'; @@ -24,6 +28,8 @@ export class AccountController { private readonly mail: PortalMailService, private readonly config: ConfigService, private readonly audit: AuditService, + private readonly applicationErrorLogger: ApplicationErrorLoggerService, + private readonly requestContext: RequestContextService, @InjectRepository(EmailChangeRequest) private readonly emailChanges: Repository, @InjectRepository(AccountDeleteRequest) @@ -64,7 +70,28 @@ export class AccountController { expiresAt: new Date(Date.now() + 24 * 60 * 60_000), }), ); - await this.mail.sendEmailChangeMail(dto.newEmail, token); + try { + await this.mail.sendEmailChangeMail(dto.newEmail, token); + } catch (error) { + await this.applicationErrorLogger.log({ + error, + category: ApplicationErrorCategory.EMAIL, + code: ApplicationErrorCode.ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED, + module: 'AccountModule', + service: AccountController.name, + operation: 'sendEmailChangeMail', + requestContext: { + ...this.requestContext.get(), + userId: request.user.username, + }, + context: { + maskedRecipient: maskEmail(dto.newEmail.toLowerCase()), + mailProvider: this.config.get('SMTP_HOST') ?? 'smtp', + }, + handled: true, + }); + throw error; + } await this.audit.record({ type: 'account.email_change_requested', username: request.user.username, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index eaf4878..1d059b5 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,6 +10,7 @@ import { OidcModule } from './oidc/oidc.module'; import { PasswordModule } from './password/password.module'; import { RegistrationModule } from './registration/registration.module'; import { AccountModule } from './account/account.module'; +import { ApplicationErrorLogModule } from './application-error-log/application-error-log.module'; @Module({ imports: [ @@ -52,6 +53,7 @@ import { AccountModule } from './account/account.module'; }; }, }), + ApplicationErrorLogModule, AuditModule, AdminModule, MailModule, diff --git a/apps/api/src/application-error-log/application-error-codes.ts b/apps/api/src/application-error-log/application-error-codes.ts new file mode 100644 index 0000000..ea157fb --- /dev/null +++ b/apps/api/src/application-error-log/application-error-codes.ts @@ -0,0 +1,21 @@ +export enum ApplicationErrorCategory { + BACKGROUND_JOB = 'BACKGROUND_JOB', + DATABASE = 'DATABASE', + EMAIL = 'EMAIL', + EXTERNAL_API = 'EXTERNAL_API', + FILE = 'FILE', + UNHANDLED = 'UNHANDLED', +} + +export enum ApplicationErrorCode { + ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED = 'ACCOUNT_EMAIL_CHANGE_EMAIL_SEND_FAILED', + BACKGROUND_JOB_FAILED = 'BACKGROUND_JOB_FAILED', + DATABASE_OPERATION_FAILED = 'DATABASE_OPERATION_FAILED', + EMAIL_PROVIDER_UNAVAILABLE = 'EMAIL_PROVIDER_UNAVAILABLE', + EXTERNAL_API_REQUEST_FAILED = 'EXTERNAL_API_REQUEST_FAILED', + FILE_GENERATION_FAILED = 'FILE_GENERATION_FAILED', + PASSWORD_RESET_EMAIL_SEND_FAILED = 'PASSWORD_RESET_EMAIL_SEND_FAILED', + REGISTRATION_APPROVAL_NOTIFICATION_FAILED = 'REGISTRATION_APPROVAL_NOTIFICATION_FAILED', + REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED = 'REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED', + UNHANDLED_BACKEND_EXCEPTION = 'UNHANDLED_BACKEND_EXCEPTION', +} diff --git a/apps/api/src/application-error-log/application-error-log.entity.ts b/apps/api/src/application-error-log/application-error-log.entity.ts new file mode 100644 index 0000000..2a0c9b2 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-log.entity.ts @@ -0,0 +1,74 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity({ name: 'application_error_logs' }) +@Index(['createdAt']) +@Index(['code']) +@Index(['category']) +@Index(['correlationId']) +@Index(['userId']) +@Index(['tenantId']) +@Index(['httpStatusCode']) +export class ApplicationErrorLog { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ default: 'error' }) + level!: string; + + @Column({ nullable: true }) + category?: string; + + @Column({ nullable: true }) + code?: string; + + @Column({ type: 'text' }) + message!: string; + + @Column({ type: 'text', nullable: true }) + stackTrace?: string; + + @Column({ nullable: true }) + errorType?: string; + + @Column({ nullable: true }) + backendModule?: string; + + @Column({ nullable: true }) + service?: string; + + @Column({ nullable: true }) + operation?: string; + + @Column({ nullable: true }) + httpMethod?: string; + + @Column({ nullable: true }) + apiPath?: string; + + @Column({ type: 'int', nullable: true }) + httpStatusCode?: number; + + @Column({ nullable: true }) + correlationId?: string; + + @Column({ nullable: true }) + userId?: string; + + @Column({ nullable: true }) + tenantId?: string; + + @Column({ nullable: true }) + environment?: string; + + @Column({ nullable: true }) + host?: string; + + @Column({ type: 'simple-json', nullable: true }) + context?: Record; + + @Column({ default: false }) + handled!: boolean; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/apps/api/src/application-error-log/application-error-log.module.ts b/apps/api/src/application-error-log/application-error-log.module.ts new file mode 100644 index 0000000..7248f43 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-log.module.ts @@ -0,0 +1,29 @@ +import { Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationErrorFilter } from './application-error.filter'; +import { ApplicationErrorLog } from './application-error-log.entity'; +import { ApplicationErrorLoggerService } from './application-error-logger.service'; +import { CorrelationIdMiddleware } from '../common/correlation-id.middleware'; +import { RequestContextService } from '../common/request-context.service'; + +@Global() +@Module({ + imports: [ConfigModule, TypeOrmModule.forFeature([ApplicationErrorLog])], + providers: [ + RequestContextService, + CorrelationIdMiddleware, + ApplicationErrorLoggerService, + { + provide: APP_FILTER, + useClass: ApplicationErrorFilter, + }, + ], + exports: [ApplicationErrorLoggerService, RequestContextService], +}) +export class ApplicationErrorLogModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(CorrelationIdMiddleware).forRoutes('*'); + } +} diff --git a/apps/api/src/application-error-log/application-error-log.types.ts b/apps/api/src/application-error-log/application-error-log.types.ts new file mode 100644 index 0000000..00fba79 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-log.types.ts @@ -0,0 +1,21 @@ +export interface ApplicationErrorRequestContext { + correlationId?: string; + method?: string; + path?: string; + statusCode?: number; + userId?: string; + tenantId?: string; +} + +export interface ApplicationErrorLogInput { + error: unknown; + level?: string; + category?: string; + code?: string; + module?: string; + service?: string; + operation?: string; + requestContext?: ApplicationErrorRequestContext; + context?: Record; + handled?: boolean; +} diff --git a/apps/api/src/application-error-log/application-error-logger.service.spec.ts b/apps/api/src/application-error-log/application-error-logger.service.spec.ts new file mode 100644 index 0000000..62f2182 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-logger.service.spec.ts @@ -0,0 +1,213 @@ +import { BadRequestException, Logger } from '@nestjs/common'; +import { describe, expect, it, jest } from '@jest/globals'; +import { ApplicationErrorCategory, ApplicationErrorCode } from './application-error-codes'; +import { ApplicationErrorLoggerService } from './application-error-logger.service'; + +describe('ApplicationErrorLoggerService', () => { + function createService( + save = jest.fn<(entity: unknown) => Promise>().mockResolvedValue(undefined), + ) { + const repository = { + create: jest.fn((entity) => entity), + save, + }; + const config = { + get: jest.fn((key: string) => (key === 'NODE_ENV' ? 'test' : undefined)), + }; + + return { + service: new ApplicationErrorLoggerService(repository as never, config as never), + repository, + save, + }; + } + + it('stores a normal Error object with stack and type', async () => { + const { service, save } = createService(); + const error = new Error('SMTP failed'); + + await service.log({ + error, + category: ApplicationErrorCategory.EMAIL, + code: ApplicationErrorCode.EMAIL_PROVIDER_UNAVAILABLE, + module: 'MailModule', + service: 'PortalMailService', + operation: 'sendMail', + handled: true, + }); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + category: ApplicationErrorCategory.EMAIL, + code: ApplicationErrorCode.EMAIL_PROVIDER_UNAVAILABLE, + message: 'SMTP failed', + errorType: 'Error', + stackTrace: expect.stringContaining('Error: SMTP failed'), + handled: true, + }), + ); + }); + + it('processes NestJS HTTP exceptions', async () => { + const { service, save } = createService(); + + await service.log({ + error: new BadRequestException({ statusCode: 400, code: 'VALIDATION_FAILED', message: ['email invalid'] }), + handled: true, + }); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'VALIDATION_FAILED', + httpStatusCode: 400, + errorType: 'BadRequestException', + level: 'warning', + }), + ); + }); + + it('safely stores unknown error values', async () => { + const { service, save } = createService(); + + await service.log({ error: { reason: 'plain object' }, handled: true }); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + message: '{"reason":"plain object"}', + errorType: 'object', + }), + ); + }); + + it('removes sensitive fields including nested values', async () => { + const { service, save } = createService(); + + await service.log({ + error: new Error('failed'), + context: { + password: 'secret', + headers: { + authorization: 'Bearer abc', + safe: 'value', + }, + nested: [{ resetToken: 'secret-token', visible: true }], + }, + }); + + const saved = save.mock.calls[0][0] as { context: Record }; + expect(saved.context.password).toBeUndefined(); + expect((saved.context.headers as Record).authorization).toBeUndefined(); + expect((saved.context.headers as Record).safe).toBe('value'); + expect(((saved.context.nested as Array>)[0]).resetToken).toBeUndefined(); + }); + + it('masks email addresses in context values', async () => { + const { service, save } = createService(); + + await service.log({ + error: new Error('failed for max.mustermann@example.com'), + context: { + recipient: 'max.mustermann@example.com', + }, + }); + + const saved = save.mock.calls[0][0] as { message: string; context: Record }; + expect(saved.message).toBe('failed for m***@example.com'); + expect(saved.context.recipient).toBe('m***@example.com'); + }); + + it('handles circular context values safely', async () => { + const { service, save } = createService(); + const circular: Record = { name: 'root' }; + circular.self = circular; + + await service.log({ + error: new Error('failed'), + context: circular, + }); + + const saved = save.mock.calls[0][0] as { context: Record }; + expect(saved.context.self).toBe('[Circular]'); + }); + + it('limits oversized context payloads', async () => { + const { service, save } = createService(); + + await service.log({ + error: new Error('failed'), + context: { + items: Array.from({ length: 20 }, () => 'x'.repeat(2_000)), + }, + }); + + const saved = save.mock.calls[0][0] as { context: Record }; + expect(saved.context.truncated).toBe(true); + }); + + it('stores correlation, user and tenant context', async () => { + const { service, save } = createService(); + + await service.log({ + error: new Error('failed'), + requestContext: { + correlationId: 'corr-1', + method: 'POST', + path: '/api/password/reset/request', + userId: 'user-1', + tenantId: 'tenant-1', + }, + }); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: 'corr-1', + httpMethod: 'POST', + apiPath: '/api/password/reset/request', + userId: 'user-1', + tenantId: 'tenant-1', + }), + ); + }); + + it('does not throw when database logging fails', async () => { + const loggerSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const { service } = createService( + jest.fn<(entity: unknown) => Promise>().mockRejectedValue(new Error('database unavailable')), + ); + + await expect(service.log({ error: new Error('original') })).resolves.toBeUndefined(); + expect(loggerSpy).toHaveBeenCalled(); + loggerSpy.mockRestore(); + }); + + it('can be used for handled background job failures', async () => { + const { service, save } = createService(); + + await service.log({ + error: new Error('job failed'), + category: ApplicationErrorCategory.BACKGROUND_JOB, + code: ApplicationErrorCode.BACKGROUND_JOB_FAILED, + service: 'NightlyImportJob', + operation: 'run', + handled: true, + }); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + category: ApplicationErrorCategory.BACKGROUND_JOB, + code: ApplicationErrorCode.BACKGROUND_JOB_FAILED, + handled: true, + }), + ); + }); + + it('does not persist the same Error instance twice', async () => { + const { service, save } = createService(); + const error = new Error('same failure'); + + await service.log({ error }); + await service.log({ error }); + + expect(save).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/application-error-log/application-error-logger.service.ts b/apps/api/src/application-error-log/application-error-logger.service.ts new file mode 100644 index 0000000..fa1faa9 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-logger.service.ts @@ -0,0 +1,127 @@ +import { HttpException, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { hostname } from 'node:os'; +import { Repository } from 'typeorm'; +import { ApplicationErrorLog } from './application-error-log.entity'; +import { ApplicationErrorLogInput } from './application-error-log.types'; +import { markErrorAsLogged, wasErrorLogged } from './logged-error-marker'; +import { sanitizeContext, sanitizeString, sanitizeValue } from './application-error-sanitizer'; + +interface ExtractedError { + message: string; + stackTrace?: string; + errorType?: string; + httpStatusCode?: number; + responseCode?: string; +} + +@Injectable() +export class ApplicationErrorLoggerService { + private readonly fallbackLogger = new Logger(ApplicationErrorLoggerService.name); + + constructor( + @InjectRepository(ApplicationErrorLog) + private readonly logs: Repository, + private readonly config: ConfigService, + ) {} + + async log(input: ApplicationErrorLogInput): Promise { + if (wasErrorLogged(input.error)) { + return; + } + + const extracted = this.extractError(input.error); + try { + const entity = this.logs.create({ + level: input.level ?? this.levelForStatus(input.requestContext?.statusCode ?? extracted.httpStatusCode), + category: input.category, + code: input.code ?? extracted.responseCode, + message: extracted.message, + stackTrace: extracted.stackTrace, + errorType: extracted.errorType, + backendModule: input.module, + service: input.service, + operation: input.operation, + httpMethod: input.requestContext?.method, + apiPath: input.requestContext?.path, + httpStatusCode: input.requestContext?.statusCode ?? extracted.httpStatusCode, + correlationId: input.requestContext?.correlationId, + userId: input.requestContext?.userId, + tenantId: input.requestContext?.tenantId, + environment: this.config.get('NODE_ENV') ?? 'development', + host: hostname(), + context: sanitizeContext(input.context), + handled: input.handled ?? false, + }); + + await this.logs.save(entity); + markErrorAsLogged(input.error); + } catch (logError) { + this.fallbackLogger.error( + `Application error log write failed: ${this.extractError(logError).message}; original: ${extracted.message}`, + this.extractError(logError).stackTrace, + ); + } + } + + private extractError(error: unknown): ExtractedError { + if (error instanceof HttpException) { + const response = error.getResponse(); + const responseObject = typeof response === 'object' && response !== null ? response : undefined; + const responseCode = + responseObject && 'code' in responseObject && typeof responseObject.code === 'string' + ? responseObject.code + : undefined; + + return { + message: sanitizeString(error.message || this.messageFromResponse(response)), + stackTrace: error.stack ? sanitizeString(error.stack) : undefined, + errorType: error.constructor.name, + httpStatusCode: error.getStatus(), + responseCode, + }; + } + + if (error instanceof Error) { + return { + message: sanitizeString(error.message || error.name), + stackTrace: error.stack ? sanitizeString(error.stack) : undefined, + errorType: error.constructor.name, + }; + } + + if (typeof error === 'string') { + return { + message: sanitizeString(error), + errorType: 'String', + }; + } + + return { + message: sanitizeString(JSON.stringify(sanitizeValue(error)) ?? 'Unknown non-error value'), + errorType: error === null ? 'Null' : typeof error, + }; + } + + private messageFromResponse(response: string | object): string { + if (typeof response === 'string') { + return response; + } + + if ('message' in response) { + const message = response.message; + return Array.isArray(message) ? message.join('; ') : String(message); + } + + return 'HTTP exception'; + } + + private levelForStatus(statusCode?: number): string { + if (!statusCode || statusCode >= 500) { + return 'error'; + } + + return 'warning'; + } +} diff --git a/apps/api/src/application-error-log/application-error-sanitizer.ts b/apps/api/src/application-error-log/application-error-sanitizer.ts new file mode 100644 index 0000000..d840be4 --- /dev/null +++ b/apps/api/src/application-error-log/application-error-sanitizer.ts @@ -0,0 +1,131 @@ +const sensitiveKeyFragments = [ + 'password', + 'currentpassword', + 'newpassword', + 'token', + 'accesstoken', + 'refreshtoken', + 'idtoken', + 'authorization', + 'cookie', + 'secret', + 'apikey', + 'clientsecret', + 'resettoken', + 'sessionid', +]; + +const maxDepth = 5; +const maxObjectKeys = 50; +const maxArrayItems = 20; +const maxStringLength = 2_000; +const maxContextLength = 16_000; + +export function isSensitiveKey(key: string): boolean { + const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase(); + return sensitiveKeyFragments.some((fragment) => normalized.includes(fragment)); +} + +export function maskEmail(email: string): string { + const [localPart, domain] = email.split('@'); + if (!localPart || !domain) { + return email; + } + + return `${localPart[0] ?? '*'}***@${domain}`; +} + +export function sanitizeString(value: string): string { + const withoutBearer = value.replace(/bearer\s+[a-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'); + const withMaskedEmails = withoutBearer.replace( + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, + (email) => maskEmail(email), + ); + + return truncate(withMaskedEmails, maxStringLength); +} + +export function sanitizeContext(input: unknown): Record | undefined { + const sanitized = sanitizeValue(input, 0, new WeakSet()); + if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) { + return undefined; + } + + const serialized = JSON.stringify(sanitized); + if (serialized.length <= maxContextLength) { + return sanitized as Record; + } + + return { + truncated: true, + originalLength: serialized.length, + preview: serialized.slice(0, maxContextLength), + }; +} + +export function sanitizeValue(input: unknown, depth = 0, seen = new WeakSet()): unknown { + if (input === null || input === undefined) { + return input; + } + + if (typeof input === 'string') { + return sanitizeString(input); + } + + if (typeof input === 'number' || typeof input === 'boolean') { + return input; + } + + if (typeof input === 'bigint') { + return input.toString(); + } + + if (typeof input === 'symbol' || typeof input === 'function') { + return `[${typeof input}]`; + } + + if (input instanceof Date) { + return input.toISOString(); + } + + if (input instanceof Error) { + return { + name: input.name, + message: sanitizeString(input.message), + stack: input.stack ? sanitizeString(input.stack) : undefined, + }; + } + + if (depth >= maxDepth) { + return '[MaxDepth]'; + } + + if (seen.has(input)) { + return '[Circular]'; + } + + seen.add(input); + + if (Array.isArray(input)) { + return input.slice(0, maxArrayItems).map((item) => sanitizeValue(item, depth + 1, seen)); + } + + const output: Record = {}; + for (const [key, value] of Object.entries(input).slice(0, maxObjectKeys)) { + if (isSensitiveKey(key)) { + continue; + } + + output[key] = sanitizeValue(value, depth + 1, seen); + } + + return output; +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength)}...[truncated]`; +} diff --git a/apps/api/src/application-error-log/application-error.filter.spec.ts b/apps/api/src/application-error-log/application-error.filter.spec.ts new file mode 100644 index 0000000..9e7cf87 --- /dev/null +++ b/apps/api/src/application-error-log/application-error.filter.spec.ts @@ -0,0 +1,81 @@ +import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; +import { describe, expect, it, jest } from '@jest/globals'; +import { ApplicationErrorFilter } from './application-error.filter'; +import { ApplicationErrorCode } from './application-error-codes'; +import { markErrorAsLogged } from './logged-error-marker'; + +describe('ApplicationErrorFilter', () => { + function createFilter() { + const reply = jest.fn(); + const errorLogger = { log: jest.fn<(input: unknown) => Promise>().mockResolvedValue(undefined) }; + const requestContext = { + get: jest.fn(() => ({ correlationId: 'corr-1' })), + }; + const filter = new ApplicationErrorFilter( + { httpAdapter: { reply } } as never, + errorLogger as never, + requestContext as never, + ); + const host = { + switchToHttp: () => ({ + getRequest: () => ({ + method: 'POST', + route: { path: '/test' }, + path: '/test', + originalUrl: '/test?x=1', + url: '/test?x=1', + query: { x: '1' }, + params: { id: '2' }, + user: { username: 'user-1', sub: 'sub-1' }, + }), + getResponse: () => ({}), + }), + }; + + return { filter, reply, errorLogger, host }; + } + + it('does not log expected validation errors as critical failures', () => { + const { filter, reply, errorLogger, host } = createFilter(); + + filter.catch(new BadRequestException('invalid input'), host as never); + + expect(errorLogger.log).not.toHaveBeenCalled(); + expect(reply).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ statusCode: 400 }), 400); + }); + + it('logs unexpected exceptions globally', () => { + const { filter, reply, errorLogger, host } = createFilter(); + const error = new Error('boom'); + + filter.catch(error, host as never); + + expect(errorLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + error, + code: ApplicationErrorCode.UNHANDLED_BACKEND_EXCEPTION, + requestContext: expect.objectContaining({ + correlationId: 'corr-1', + userId: 'user-1', + statusCode: 500, + }), + handled: false, + }), + ); + expect(reply).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ statusCode: 500, correlationId: 'corr-1' }), + 500, + ); + }); + + it('does not log exceptions that were already explicitly recorded', () => { + const { filter, errorLogger, host } = createFilter(); + const exception = new InternalServerErrorException('already logged'); + markErrorAsLogged(exception); + + filter.catch(exception, host as never); + + expect(errorLogger.log).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/application-error-log/application-error.filter.ts b/apps/api/src/application-error-log/application-error.filter.ts new file mode 100644 index 0000000..978ec8d --- /dev/null +++ b/apps/api/src/application-error-log/application-error.filter.ts @@ -0,0 +1,103 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { Request } from 'express'; +import { ApplicationErrorCategory, ApplicationErrorCode } from './application-error-codes'; +import { ApplicationErrorLoggerService } from './application-error-logger.service'; +import { wasErrorLogged } from './logged-error-marker'; +import { RequestContextService } from '../common/request-context.service'; +import { RequestUser } from '../common/request-user'; + +@Catch() +@Injectable() +export class ApplicationErrorFilter implements ExceptionFilter { + constructor( + private readonly httpAdapterHost: HttpAdapterHost, + private readonly errorLogger: ApplicationErrorLoggerService, + private readonly requestContext: RequestContextService, + ) {} + + catch(exception: unknown, host: ArgumentsHost): void { + const http = host.switchToHttp(); + const request = http.getRequest(); + const response = http.getResponse(); + const statusCode = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + const context = this.requestContext.get(); + const correlationId = context.correlationId; + + if (this.shouldLog(exception, statusCode)) { + void this.errorLogger.log({ + error: exception, + category: this.categoryFor(exception), + code: ApplicationErrorCode.UNHANDLED_BACKEND_EXCEPTION, + module: 'HTTP', + operation: `${request.method} ${request.route?.path ?? request.path}`, + requestContext: { + correlationId, + method: request.method, + path: request.originalUrl || request.url, + statusCode, + userId: request.user?.username ?? request.user?.sub, + }, + context: { + query: request.query, + params: request.params, + }, + handled: false, + }); + } + + const body = this.responseBody(exception, statusCode, correlationId); + this.httpAdapterHost.httpAdapter.reply(response, body, statusCode); + } + + private shouldLog(exception: unknown, statusCode: number): boolean { + if (wasErrorLogged(exception)) { + return false; + } + + if (!(exception instanceof HttpException)) { + return true; + } + + return statusCode >= 500; + } + + private categoryFor(exception: unknown): ApplicationErrorCategory { + if (exception instanceof HttpException) { + return ApplicationErrorCategory.UNHANDLED; + } + + return ApplicationErrorCategory.UNHANDLED; + } + + private responseBody(exception: unknown, statusCode: number, correlationId?: string): unknown { + if (exception instanceof HttpException) { + const exceptionResponse = exception.getResponse(); + if (typeof exceptionResponse === 'string') { + return { + statusCode, + message: exceptionResponse, + ...(statusCode >= 500 && correlationId ? { correlationId } : {}), + }; + } + + return { + ...exceptionResponse, + ...(statusCode >= 500 && correlationId ? { correlationId } : {}), + }; + } + + return { + statusCode, + message: 'Internal server error', + ...(correlationId ? { correlationId } : {}), + }; + } +} diff --git a/apps/api/src/application-error-log/logged-error-marker.ts b/apps/api/src/application-error-log/logged-error-marker.ts new file mode 100644 index 0000000..acd0186 --- /dev/null +++ b/apps/api/src/application-error-log/logged-error-marker.ts @@ -0,0 +1,25 @@ +const loggedErrorMarker = Symbol('applicationErrorLogged'); + +export function markErrorAsLogged(error: unknown): void { + if (!error || (typeof error !== 'object' && typeof error !== 'function')) { + return; + } + + if (wasErrorLogged(error)) { + return; + } + + Object.defineProperty(error, loggedErrorMarker, { + value: true, + configurable: false, + enumerable: false, + }); +} + +export function wasErrorLogged(error: unknown): boolean { + return Boolean( + error && + (typeof error === 'object' || typeof error === 'function') && + (error as Record)[loggedErrorMarker], + ); +} diff --git a/apps/api/src/common/correlation-id.middleware.ts b/apps/api/src/common/correlation-id.middleware.ts new file mode 100644 index 0000000..c4bdf1b --- /dev/null +++ b/apps/api/src/common/correlation-id.middleware.ts @@ -0,0 +1,26 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { NextFunction, Request, Response } from 'express'; +import { RequestContextService } from './request-context.service'; + +export const correlationIdHeader = 'x-correlation-id'; + +@Injectable() +export class CorrelationIdMiddleware implements NestMiddleware { + constructor(private readonly requestContext: RequestContextService) {} + + use(request: Request, response: Response, next: NextFunction): void { + const header = request.headers[correlationIdHeader]; + const correlationId = Array.isArray(header) ? header[0] : header || randomUUID(); + + response.setHeader('X-Correlation-ID', correlationId); + this.requestContext.run( + { + correlationId, + method: request.method, + path: request.originalUrl || request.url, + }, + next, + ); + } +} diff --git a/apps/api/src/common/request-context.service.ts b/apps/api/src/common/request-context.service.ts new file mode 100644 index 0000000..4e7dbfa --- /dev/null +++ b/apps/api/src/common/request-context.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +export interface RequestContextData { + correlationId?: string; + method?: string; + path?: string; + userId?: string; + tenantId?: string; +} + +@Injectable() +export class RequestContextService { + private readonly storage = new AsyncLocalStorage(); + + run(context: RequestContextData, callback: () => T): T { + return this.storage.run(context, callback); + } + + get(): RequestContextData { + return this.storage.getStore() ?? {}; + } +} diff --git a/apps/api/src/lldap/lldap.service.ts b/apps/api/src/lldap/lldap.service.ts index a00a859..8ff13b1 100644 --- a/apps/api/src/lldap/lldap.service.ts +++ b/apps/api/src/lldap/lldap.service.ts @@ -1,6 +1,10 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Ber, BerWriter, Client } from 'ldapts'; +import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; +import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; +import { markErrorAsLogged } from '../application-error-log/logged-error-marker'; +import { RequestContextService } from '../common/request-context.service'; interface LldapUserInput { username: string; @@ -75,7 +79,11 @@ export class LldapService { private cachedHeaders?: { expiresAt: number; headers: Record }; - constructor(private readonly config: ConfigService) {} + constructor( + private readonly config: ConfigService, + private readonly applicationErrorLogger: ApplicationErrorLoggerService, + private readonly requestContext: RequestContextService, + ) {} async createUser(input: LldapUserInput): Promise { await this.graphql( @@ -113,7 +121,26 @@ export class LldapService { this.passwordModifyRequestValue(this.userDn(username), password), ); } catch (error) { - throw new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`); + await this.applicationErrorLogger.log({ + error, + category: ApplicationErrorCategory.EXTERNAL_API, + code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED, + module: 'LldapModule', + service: LldapService.name, + operation: 'setPassword', + requestContext: { + ...this.requestContext.get(), + userId: username, + }, + context: { + provider: 'LLDAP', + protocol: 'LDAP', + }, + handled: true, + }); + const exception = new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`); + markErrorAsLogged(exception); + throw exception; } finally { await client.unbind().catch(() => undefined); } @@ -394,15 +421,22 @@ export class LldapService { private async graphql(query: string, variables: Record): Promise { const endpoint = `${this.config.getOrThrow('LLDAP_URL').replace(/\/$/, '')}/api/graphql`; - const headers = await this.adminHeaders(); - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...headers, - }, - body: JSON.stringify({ query, variables }), - }); + let response: Response; + try { + const headers = await this.adminHeaders(); + response = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...headers, + }, + body: JSON.stringify({ query, variables }), + }); + } catch (error) { + await this.logGraphqlFailure(error, endpoint, 'request_failed'); + markErrorAsLogged(error); + throw error; + } const payload = (await response.json().catch(() => ({}))) as { data?: T; @@ -414,11 +448,21 @@ export class LldapService { if (/not found/i.test(message)) { throw new NotFoundException('LLDAP user not found'); } - throw new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`); + const exception = new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`); + await this.logGraphqlFailure(exception, endpoint, 'bad_response', { + status: response.status, + statusText: response.statusText, + errorMessages: payload.errors?.map((error) => error.message), + }); + markErrorAsLogged(exception); + throw exception; } if (!payload.data) { - throw new InternalServerErrorException('LLDAP GraphQL response did not contain data'); + const exception = new InternalServerErrorException('LLDAP GraphQL response did not contain data'); + await this.logGraphqlFailure(exception, endpoint, 'missing_data', { status: response.status }); + markErrorAsLogged(exception); + throw exception; } return payload.data; @@ -484,4 +528,28 @@ export class LldapService { private errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'unknown error'; } + + private async logGraphqlFailure( + error: unknown, + endpoint: string, + reason: string, + context: Record = {}, + ): Promise { + await this.applicationErrorLogger.log({ + error, + category: ApplicationErrorCategory.EXTERNAL_API, + code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED, + module: 'LldapModule', + service: LldapService.name, + operation: 'graphql', + requestContext: this.requestContext.get(), + context: { + provider: 'LLDAP', + endpoint, + reason, + ...context, + }, + handled: true, + }); + } } diff --git a/apps/api/src/migrations/1721200000000-CreateApplicationErrorLogs.ts b/apps/api/src/migrations/1721200000000-CreateApplicationErrorLogs.ts new file mode 100644 index 0000000..c5ae532 --- /dev/null +++ b/apps/api/src/migrations/1721200000000-CreateApplicationErrorLogs.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateApplicationErrorLogs1721200000000 implements MigrationInterface { + name = 'CreateApplicationErrorLogs1721200000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'application_error_logs', + columns: [ + { name: 'id', type: 'varchar', length: '36', isPrimary: true }, + { name: 'level', type: 'varchar', length: '255', default: "'error'" }, + { name: 'category', type: 'varchar', length: '255', isNullable: true }, + { name: 'code', type: 'varchar', length: '255', isNullable: true }, + { name: 'message', type: 'text' }, + { name: 'stackTrace', type: 'text', isNullable: true }, + { name: 'errorType', type: 'varchar', length: '255', isNullable: true }, + { name: 'backendModule', type: 'varchar', length: '255', isNullable: true }, + { name: 'service', type: 'varchar', length: '255', isNullable: true }, + { name: 'operation', type: 'varchar', length: '255', isNullable: true }, + { name: 'httpMethod', type: 'varchar', length: '255', isNullable: true }, + { name: 'apiPath', type: 'varchar', length: '255', isNullable: true }, + { name: 'httpStatusCode', type: 'int', isNullable: true }, + { name: 'correlationId', type: 'varchar', length: '255', isNullable: true }, + { name: 'userId', type: 'varchar', length: '255', isNullable: true }, + { name: 'tenantId', type: 'varchar', length: '255', isNullable: true }, + { name: 'environment', type: 'varchar', length: '255', isNullable: true }, + { name: 'host', type: 'varchar', length: '255', isNullable: true }, + { name: 'context', type: 'text', isNullable: true }, + { name: 'handled', type: 'tinyint', default: 0 }, + { + name: 'createdAt', + type: 'datetime', + precision: 6, + default: 'CURRENT_TIMESTAMP(6)', + }, + ], + }), + ); + + for (const columnName of [ + 'createdAt', + 'code', + 'category', + 'correlationId', + 'userId', + 'tenantId', + 'httpStatusCode', + ]) { + await queryRunner.createIndex( + 'application_error_logs', + new TableIndex({ + name: `IDX_application_error_logs_${columnName}`, + columnNames: [columnName], + }), + ); + } + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('application_error_logs'); + } +} diff --git a/apps/api/src/password/password.service.spec.ts b/apps/api/src/password/password.service.spec.ts new file mode 100644 index 0000000..bbd7821 --- /dev/null +++ b/apps/api/src/password/password.service.spec.ts @@ -0,0 +1,68 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { describe, expect, it, jest } from '@jest/globals'; +import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; +import { PasswordService } from './password.service'; + +describe('PasswordService', () => { + it('logs password reset email failures with the stable error code', async () => { + const resetTokens = { + create: jest.fn((input) => input), + save: jest.fn(async (input: Record) => ({ ...input, id: 'reset-token-id' })), + delete: jest.fn<() => Promise>().mockResolvedValue(undefined), + }; + const config = { + get: jest.fn((key: string) => (key === 'SMTP_HOST' ? 'smtp.example.com' : undefined)), + getOrThrow: jest.fn(() => 'test-secret'), + }; + const lldap = { + findUserByEmail: jest + .fn<(email: string) => Promise<{ id: string; email: string }>>() + .mockResolvedValue({ id: 'user-1', email: 'max@example.com' }), + }; + const mailError = new Error('provider rejected max@example.com'); + const mail = { + sendPasswordResetMail: jest.fn<(email: string, token: string) => Promise>().mockRejectedValue(mailError), + }; + const audit = { + record: jest.fn<(input: unknown) => Promise>().mockResolvedValue(undefined), + }; + const applicationErrorLogger = { + log: jest.fn<(input: unknown) => Promise>().mockResolvedValue(undefined), + }; + const requestContext = { + get: jest.fn(() => ({ correlationId: 'corr-1', method: 'POST', path: '/password/reset/request' })), + }; + const service = new PasswordService( + resetTokens as never, + config as never, + {} as never, + lldap as never, + mail as never, + audit as never, + applicationErrorLogger as never, + requestContext as never, + ); + + await expect(service.requestReset('max@example.com', '127.0.0.1', 'jest')).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + + expect(applicationErrorLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + error: mailError, + category: ApplicationErrorCategory.EMAIL, + code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED, + operation: 'sendPasswordResetEmail', + requestContext: expect.objectContaining({ + correlationId: 'corr-1', + userId: 'user-1', + }), + context: expect.objectContaining({ + maskedRecipient: 'm***@example.com', + mailProvider: 'smtp.example.com', + }), + handled: true, + }), + ); + }); +}); diff --git a/apps/api/src/password/password.service.ts b/apps/api/src/password/password.service.ts index 77b963a..900bdca 100644 --- a/apps/api/src/password/password.service.ts +++ b/apps/api/src/password/password.service.ts @@ -3,11 +3,16 @@ import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Repository } from 'typeorm'; import { AuditService } from '../audit/audit.service'; +import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; +import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; +import { markErrorAsLogged } from '../application-error-log/logged-error-marker'; import { assertPasswordPolicy } from '../common/password-policy'; +import { RequestContextService } from '../common/request-context.service'; import { hashToken, randomToken } from '../common/token.util'; import { LdapAuthService } from '../lldap/ldap-auth.service'; import { LldapService } from '../lldap/lldap.service'; import { PortalMailService } from '../mail/portal-mail.service'; +import { maskEmail } from '../application-error-log/application-error-sanitizer'; import { PasswordResetToken } from './password-reset-token.entity'; @Injectable() @@ -20,6 +25,8 @@ export class PasswordService { private readonly lldap: LldapService, private readonly mail: PortalMailService, private readonly audit: AuditService, + private readonly applicationErrorLogger: ApplicationErrorLoggerService, + private readonly requestContext: RequestContextService, ) {} async changePassword( @@ -73,6 +80,24 @@ export class PasswordService { await this.mail.sendPasswordResetMail(user.email, token); } catch (error) { await this.resetTokens.delete({ id: resetToken.id }).catch(() => undefined); + const currentRequestContext = this.requestContext.get(); + await this.applicationErrorLogger.log({ + error, + category: ApplicationErrorCategory.EMAIL, + code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED, + module: 'PasswordModule', + service: PasswordService.name, + operation: 'sendPasswordResetEmail', + requestContext: { + ...currentRequestContext, + userId: user.id, + }, + context: { + maskedRecipient: maskEmail(user.email), + mailProvider: this.config.get('SMTP_HOST') ?? 'smtp', + }, + handled: true, + }); await this.audit .record({ type: 'password.reset_mail_failed', @@ -82,9 +107,11 @@ export class PasswordService { metadata: { error: this.errorMessage(error) }, }) .catch(() => undefined); - throw new ServiceUnavailableException( + const exception = new ServiceUnavailableException( 'Der Reset-Link konnte nicht versendet werden. Bitte versuche es spaeter erneut.', ); + markErrorAsLogged(exception); + throw exception; } await this.audit.record({ diff --git a/apps/api/src/registration/registration.service.ts b/apps/api/src/registration/registration.service.ts index 2a1654c..6b9b5e1 100644 --- a/apps/api/src/registration/registration.service.ts +++ b/apps/api/src/registration/registration.service.ts @@ -3,7 +3,12 @@ import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Repository } from 'typeorm'; import { AuditService } from '../audit/audit.service'; +import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; +import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; +import { maskEmail } from '../application-error-log/application-error-sanitizer'; +import { markErrorAsLogged } from '../application-error-log/logged-error-marker'; import { assertPasswordPolicy } from '../common/password-policy'; +import { RequestContextService } from '../common/request-context.service'; import { decryptSecret, encryptSecret, hashToken, randomToken } from '../common/token.util'; import { LldapService } from '../lldap/lldap.service'; import { PortalMailService } from '../mail/portal-mail.service'; @@ -22,6 +27,8 @@ export class RegistrationService { private readonly lldap: LldapService, private readonly mail: PortalMailService, private readonly audit: AuditService, + private readonly applicationErrorLogger: ApplicationErrorLoggerService, + private readonly requestContext: RequestContextService, ) {} async register(dto: RegisterDto, ipAddress?: string, userAgent?: string) { @@ -65,6 +72,23 @@ export class RegistrationService { } catch (error) { await this.emailTokens.delete({ registrationId: registration.id }).catch(() => undefined); await this.registrations.delete({ id: registration.id }).catch(() => undefined); + await this.applicationErrorLogger.log({ + error, + category: ApplicationErrorCategory.EMAIL, + code: ApplicationErrorCode.REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED, + module: 'RegistrationModule', + service: RegistrationService.name, + operation: 'sendVerificationMail', + requestContext: { + ...this.requestContext.get(), + userId: registration.username, + }, + context: { + maskedRecipient: maskEmail(registration.email), + mailProvider: this.config.get('SMTP_HOST') ?? 'smtp', + }, + handled: true, + }); await this.audit .record({ type: 'registration.verification_mail_failed', @@ -74,9 +98,11 @@ export class RegistrationService { metadata: { error: this.errorMessage(error) }, }) .catch(() => undefined); - throw new ServiceUnavailableException( + const exception = new ServiceUnavailableException( 'Die Bestaetigungs-E-Mail konnte nicht versendet werden. Bitte versuche es spaeter erneut.', ); + markErrorAsLogged(exception); + throw exception; } await this.audit.record({ @@ -114,13 +140,30 @@ export class RegistrationService { userAgent, }); - await this.notifyUserManagers(registration).catch((error) => - this.audit.record({ + await this.notifyUserManagers(registration).catch(async (error) => { + await this.applicationErrorLogger.log({ + error, + category: ApplicationErrorCategory.EMAIL, + code: ApplicationErrorCode.REGISTRATION_APPROVAL_NOTIFICATION_FAILED, + module: 'RegistrationModule', + service: RegistrationService.name, + operation: 'notifyUserManagers', + requestContext: { + ...this.requestContext.get(), + userId: registration.username, + }, + context: { + registrationId: registration.id, + maskedRecipient: maskEmail(registration.email), + }, + handled: true, + }); + return this.audit.record({ type: 'registration.approval_notification_failed', username: registration.username, metadata: { error: this.errorMessage(error) }, - }), - ); + }); + }); return { message: 'Die E-Mail wurde bestaetigt. Die Registrierung wartet jetzt auf Freigabe.' }; } diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json index 4ac4be8..6b44642 100644 --- a/apps/api/tsconfig.build.json +++ b/apps/api/tsconfig.build.json @@ -3,5 +3,6 @@ "compilerOptions": { "declaration": true, "removeComments": true - } + }, + "exclude": ["dist", "node_modules", "test", "src/**/*.spec.ts"] } diff --git a/docs/application-error-logging.md b/docs/application-error-logging.md new file mode 100644 index 0000000..2d07df7 --- /dev/null +++ b/docs/application-error-logging.md @@ -0,0 +1,75 @@ +# Application Error Logging + +Das Backend speichert technische Fehler zentral in `application_error_logs`. Die Tabelle ist fuer Fehleranalyse gedacht, nicht fuer fachliches Audit-Logging; bestehende Audit-Events bleiben unveraendert. + +## Felder + +- `level`: technischer Schweregrad, aktuell `error` oder `warning`. +- `category`: grobe Fehlergruppe, z. B. `EMAIL`, `EXTERNAL_API`, `DATABASE`, `BACKGROUND_JOB`, `FILE`, `UNHANDLED`. +- `code`: stabiler maschinenlesbarer Fehlercode, z. B. `PASSWORD_RESET_EMAIL_SEND_FAILED`. +- `message`, `stackTrace`, `errorType`: technische Fehlerdaten aus `Error`, `HttpException` oder unbekannten Fehlerwerten. +- `backendModule`, `service`, `operation`: fachliche Herkunft im Backend. +- `httpMethod`, `apiPath`, `httpStatusCode`: HTTP-Kontext, sofern vorhanden. +- `correlationId`: Request-ID aus `X-Correlation-ID` oder eine pro Request erzeugte UUID. +- `userId`, `tenantId`: Benutzer- und Mandantenkontext, sofern vorhanden. +- `environment`, `host`: Laufzeitumgebung und Instanz. +- `context`: bereinigte strukturierte Zusatzdaten. +- `handled`: `true`, wenn der Fehler gezielt behandelt und protokolliert wurde; `false` fuer unerwartete globale Exceptions. + +## Erfassung + +Der globale NestJS-Exception-Filter protokolliert unerwartete Exceptions und technische HTTP-Fehler ab Status 500. Erwartete fachliche Fehler wie Validierung, fehlende Berechtigungen, bewusstes 404 oder Konflikte werden nicht automatisch als Systemfehler gespeichert. + +Explizit protokolliert werden derzeit: + +- Passwort-Reset-Mailfehler mit `PASSWORD_RESET_EMAIL_SEND_FAILED`. +- Registrierungs-Bestaetigungs-Mailfehler mit `REGISTRATION_VERIFICATION_EMAIL_SEND_FAILED`. +- Benachrichtigung an User-Manager mit `REGISTRATION_APPROVAL_NOTIFICATION_FAILED`. +- LLDAP/GraphQL- und LDAP-Integrationsfehler mit `EXTERNAL_API_REQUEST_FAILED`. + +Asynchrone Prozesse ohne HTTP-Request sollen den `ApplicationErrorLoggerService` direkt verwenden und `handled: true` setzen. + +## Datenschutz + +Der Logger entfernt sensible Schluessel rekursiv und case-insensitive, darunter `password`, `currentPassword`, `newPassword`, `token`, `accessToken`, `refreshToken`, `idToken`, `authorization`, `cookie`, `secret`, `apiKey`, `clientSecret`, `resetToken` und `sessionId`. + +Kontextdaten werden in Tiefe, Array-Laenge, Schluesselanzahl und String-Laenge begrenzt. Zirkulaere Objekte werden sicher ersetzt. E-Mail-Adressen in freien Texten werden maskiert, z. B. `m***@example.com`. + +## Fehlersuche + +1. Korrelations-ID aus Response-Header `X-Correlation-ID` oder Backend-Log notieren. +2. In `application_error_logs` nach `correlationId`, `code`, `category`, `userId` oder Zeitraum suchen. +3. `handled` pruefen: `false` weist auf einen unerwarteten globalen Fehler hin, `true` auf einen gezielt behandelten technischen Fehler. +4. `context` fuer Provider-Status, maskierte Empfaenger oder Integrationsdetails verwenden. + +## Beispiel + +```typescript +try { + await this.mail.sendPasswordResetMail(user.email, token); +} catch (error) { + await this.applicationErrorLogger.log({ + error, + category: ApplicationErrorCategory.EMAIL, + code: ApplicationErrorCode.PASSWORD_RESET_EMAIL_SEND_FAILED, + module: 'PasswordModule', + service: PasswordService.name, + operation: 'sendPasswordResetEmail', + requestContext: { + correlationId, + userId: user.id, + }, + context: { + maskedRecipient: maskEmail(user.email), + mailProvider, + }, + handled: true, + }); + + throw error; +} +``` + +## Aufbewahrung + +Es gibt im Projekt derzeit keinen Scheduler oder Queue-Mechanismus. Empfohlen ist ein spaeterer, begrenzter Cleanup-Job, der Fehler aelter als 180 Tage in Batches loescht und kritische Kategorien bei Bedarf laenger aufbewahrt. Die Tabelle besitzt Indizes auf `createdAt`, `code`, `category`, `correlationId`, `userId`, `tenantId` und `httpStatusCode`.