fix: 补齐异常阶段请求日志
- src/common/http/all-exceptions.filter.ts: 记录 guard/pipe 阶段的结构化错误日志 - src/main.ts: 写入请求开始时间并向异常过滤器注入结构化 logger - src/common/logging: 避免成功拦截器重复记录异常日志
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 全局异常过滤器负责统一错误 envelope,并补齐 guard/pipe 阶段的结构化错误日志。
|
||||
*/
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
@@ -11,6 +14,22 @@ import {
|
||||
ResponseWithHeaders,
|
||||
resolveRequestId,
|
||||
} from './request-id';
|
||||
import { StructuredLoggerService } from '../logging/structured-logger.service';
|
||||
|
||||
type RequestWithErrorLogging = RequestWithHeaders & {
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
ip?: string;
|
||||
socket?: {
|
||||
remoteAddress?: string;
|
||||
};
|
||||
user?: {
|
||||
id?: string;
|
||||
account?: string;
|
||||
role?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type JsonResponse = ResponseWithHeaders & {
|
||||
status: (statusCode: number) => {
|
||||
@@ -20,14 +39,17 @@ type JsonResponse = ResponseWithHeaders & {
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger?: StructuredLoggerService) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const httpContext = host.switchToHttp();
|
||||
const request = httpContext.getRequest<RequestWithHeaders>();
|
||||
const request = httpContext.getRequest<RequestWithErrorLogging>();
|
||||
const response = httpContext.getResponse<JsonResponse>();
|
||||
const payload = this.toPayload(exception);
|
||||
const requestId = resolveRequestId(request.headers);
|
||||
|
||||
response.setHeader('x-request-id', requestId);
|
||||
this.logException(request, payload, requestId);
|
||||
|
||||
response.status(payload.statusCode).json({
|
||||
success: false,
|
||||
@@ -79,4 +101,54 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
message: '系统异常,请联系管理员',
|
||||
};
|
||||
}
|
||||
|
||||
private logException(
|
||||
request: RequestWithErrorLogging,
|
||||
payload: {
|
||||
statusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
},
|
||||
requestId: string,
|
||||
): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logPayload = {
|
||||
event: 'http_request_completed',
|
||||
outcome: 'error',
|
||||
requestId,
|
||||
method: request.method,
|
||||
path: this.safePath(request.originalUrl ?? request.url),
|
||||
statusCode: payload.statusCode,
|
||||
durationMs:
|
||||
typeof request.startedAt === 'number'
|
||||
? Date.now() - request.startedAt
|
||||
: undefined,
|
||||
actorId: request.user?.id,
|
||||
actorName: request.user?.account,
|
||||
actorRole: request.user?.role,
|
||||
sourceIp: request.ip ?? request.socket?.remoteAddress,
|
||||
errorCode: payload.code,
|
||||
errorMessage: this.redactedMessage(payload.message),
|
||||
};
|
||||
|
||||
if (payload.statusCode >= 500) {
|
||||
this.logger.error(logPayload, 'http request failed');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn(logPayload, 'http request completed with client error');
|
||||
}
|
||||
|
||||
private redactedMessage(message: string): string {
|
||||
const redacted = redactSensitive({ message });
|
||||
return typeof redacted.message === 'string' ? redacted.message : '已隐藏错误信息';
|
||||
}
|
||||
|
||||
private safePath(value?: string): string {
|
||||
const path = value?.split('?')[0]?.trim();
|
||||
return path || '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type RequestHeaders = Record<string, string | string[] | undefined>;
|
||||
|
||||
export type RequestWithHeaders = {
|
||||
headers?: RequestHeaders;
|
||||
startedAt?: number;
|
||||
};
|
||||
|
||||
export type ResponseWithHeaders = {
|
||||
|
||||
@@ -4,14 +4,11 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, catchError, tap, throwError } from 'rxjs';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { AuthenticatedUser } from '../../auth/auth.types';
|
||||
import { AppError } from '../errors/app-error';
|
||||
import { redactSensitive } from '../security/redact-sensitive';
|
||||
import { RequestHeaders, resolveRequestId } from '../http/request-id';
|
||||
import { StructuredLoggerService } from './structured-logger.service';
|
||||
|
||||
@@ -49,10 +46,6 @@ export class HttpRequestLoggingInterceptor implements NestInterceptor {
|
||||
tap(() => {
|
||||
this.logRequest('success', request, response, startedAt);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logRequest('error', request, response, startedAt, error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,9 +54,8 @@ export class HttpRequestLoggingInterceptor implements NestInterceptor {
|
||||
request: RequestWithLoggingContext,
|
||||
response: ResponseWithStatus,
|
||||
startedAt: number,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const statusCode = this.resolveStatusCode(response, error);
|
||||
const statusCode = response.statusCode ?? 200;
|
||||
const payload = {
|
||||
event: 'http_request_completed',
|
||||
outcome,
|
||||
@@ -76,78 +68,11 @@ export class HttpRequestLoggingInterceptor implements NestInterceptor {
|
||||
actorName: request.user?.account,
|
||||
actorRole: request.user?.role,
|
||||
sourceIp: request.ip ?? request.socket?.remoteAddress,
|
||||
...this.errorPayload(error),
|
||||
};
|
||||
|
||||
if (statusCode >= 500) {
|
||||
this.logger.error(payload, 'http request failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode >= 400) {
|
||||
this.logger.warn(payload, 'http request completed with client error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info(payload, 'http request completed');
|
||||
}
|
||||
|
||||
private resolveStatusCode(
|
||||
response: ResponseWithStatus,
|
||||
error?: unknown,
|
||||
): number {
|
||||
if (error instanceof AppError) {
|
||||
return error.statusCode;
|
||||
}
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
return error.getStatus();
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return 500;
|
||||
}
|
||||
|
||||
return response.statusCode ?? 200;
|
||||
}
|
||||
|
||||
private errorPayload(error?: unknown): Record<string, unknown> {
|
||||
if (!error) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return {
|
||||
errorCode: error.code,
|
||||
errorMessage: this.redactedMessage(error.message),
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
return {
|
||||
errorCode: 'HTTP_EXCEPTION',
|
||||
errorMessage: this.redactedMessage('请求处理失败'),
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
errorCode: 'UNEXPECTED_ERROR',
|
||||
errorName: error.name,
|
||||
errorMessage: this.redactedMessage(error.message),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errorCode: 'UNEXPECTED_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
private redactedMessage(message: string): string {
|
||||
const redacted = redactSensitive({ message });
|
||||
return typeof redacted.message === 'string' ? redacted.message : '已隐藏错误信息';
|
||||
}
|
||||
|
||||
private safePath(value?: string): string {
|
||||
const path = value?.split('?')[0]?.trim();
|
||||
return path || '/';
|
||||
|
||||
+12
-1
@@ -5,8 +5,13 @@ import { AppModule } from './app.module';
|
||||
import { ApiEnvelopeInterceptor } from './common/http/api-envelope.interceptor';
|
||||
import { AllExceptionsFilter } from './common/http/all-exceptions.filter';
|
||||
import { HttpRequestLoggingInterceptor } from './common/logging/http-request-logging.interceptor';
|
||||
import { StructuredLoggerService } from './common/logging/structured-logger.service';
|
||||
import { EnvConfig } from './config/env.schema';
|
||||
|
||||
type RequestWithStartedAt = {
|
||||
startedAt?: number;
|
||||
};
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
bufferLogs: true,
|
||||
@@ -14,12 +19,18 @@ async function bootstrap(): Promise<void> {
|
||||
});
|
||||
const config = app.get(ConfigService<EnvConfig, true>);
|
||||
const corsOrigin = config.get('CORS_ORIGIN', { infer: true });
|
||||
const structuredLogger = app.get(StructuredLoggerService);
|
||||
|
||||
app.use((request: RequestWithStartedAt, _response: unknown, next: () => void) => {
|
||||
request.startedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigin ? corsOrigin.split(',').map((origin) => origin.trim()) : true,
|
||||
credentials: true,
|
||||
});
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
app.useGlobalFilters(new AllExceptionsFilter(structuredLogger));
|
||||
app.useGlobalInterceptors(
|
||||
app.get(HttpRequestLoggingInterceptor),
|
||||
new ApiEnvelopeInterceptor(),
|
||||
|
||||
Reference in New Issue
Block a user