fix: 补齐异常阶段请求日志

- src/common/http/all-exceptions.filter.ts: 记录 guard/pipe 阶段的结构化错误日志
- src/main.ts: 写入请求开始时间并向异常过滤器注入结构化 logger
- src/common/logging: 避免成功拦截器重复记录异常日志
This commit is contained in:
湛兮
2026-06-12 05:44:03 +08:00
parent e420968d5f
commit 9343e74b2c
4 changed files with 88 additions and 79 deletions
+73 -1
View File
@@ -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 || '/';
}
}
+1
View File
@@ -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
View File
@@ -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(),