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 {
|
import {
|
||||||
ArgumentsHost,
|
ArgumentsHost,
|
||||||
Catch,
|
Catch,
|
||||||
@@ -11,6 +14,22 @@ import {
|
|||||||
ResponseWithHeaders,
|
ResponseWithHeaders,
|
||||||
resolveRequestId,
|
resolveRequestId,
|
||||||
} from './request-id';
|
} 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 & {
|
type JsonResponse = ResponseWithHeaders & {
|
||||||
status: (statusCode: number) => {
|
status: (statusCode: number) => {
|
||||||
@@ -20,14 +39,17 @@ type JsonResponse = ResponseWithHeaders & {
|
|||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
constructor(private readonly logger?: StructuredLoggerService) {}
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const httpContext = host.switchToHttp();
|
const httpContext = host.switchToHttp();
|
||||||
const request = httpContext.getRequest<RequestWithHeaders>();
|
const request = httpContext.getRequest<RequestWithErrorLogging>();
|
||||||
const response = httpContext.getResponse<JsonResponse>();
|
const response = httpContext.getResponse<JsonResponse>();
|
||||||
const payload = this.toPayload(exception);
|
const payload = this.toPayload(exception);
|
||||||
const requestId = resolveRequestId(request.headers);
|
const requestId = resolveRequestId(request.headers);
|
||||||
|
|
||||||
response.setHeader('x-request-id', requestId);
|
response.setHeader('x-request-id', requestId);
|
||||||
|
this.logException(request, payload, requestId);
|
||||||
|
|
||||||
response.status(payload.statusCode).json({
|
response.status(payload.statusCode).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -79,4 +101,54 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
message: '系统异常,请联系管理员',
|
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 = {
|
export type RequestWithHeaders = {
|
||||||
headers?: RequestHeaders;
|
headers?: RequestHeaders;
|
||||||
|
startedAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResponseWithHeaders = {
|
export type ResponseWithHeaders = {
|
||||||
|
|||||||
@@ -4,14 +4,11 @@
|
|||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
HttpException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable, catchError, tap, throwError } from 'rxjs';
|
import { Observable, tap } from 'rxjs';
|
||||||
import { AuthenticatedUser } from '../../auth/auth.types';
|
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 { RequestHeaders, resolveRequestId } from '../http/request-id';
|
||||||
import { StructuredLoggerService } from './structured-logger.service';
|
import { StructuredLoggerService } from './structured-logger.service';
|
||||||
|
|
||||||
@@ -49,10 +46,6 @@ export class HttpRequestLoggingInterceptor implements NestInterceptor {
|
|||||||
tap(() => {
|
tap(() => {
|
||||||
this.logRequest('success', request, response, startedAt);
|
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,
|
request: RequestWithLoggingContext,
|
||||||
response: ResponseWithStatus,
|
response: ResponseWithStatus,
|
||||||
startedAt: number,
|
startedAt: number,
|
||||||
error?: unknown,
|
|
||||||
): void {
|
): void {
|
||||||
const statusCode = this.resolveStatusCode(response, error);
|
const statusCode = response.statusCode ?? 200;
|
||||||
const payload = {
|
const payload = {
|
||||||
event: 'http_request_completed',
|
event: 'http_request_completed',
|
||||||
outcome,
|
outcome,
|
||||||
@@ -76,78 +68,11 @@ export class HttpRequestLoggingInterceptor implements NestInterceptor {
|
|||||||
actorName: request.user?.account,
|
actorName: request.user?.account,
|
||||||
actorRole: request.user?.role,
|
actorRole: request.user?.role,
|
||||||
sourceIp: request.ip ?? request.socket?.remoteAddress,
|
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');
|
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 {
|
private safePath(value?: string): string {
|
||||||
const path = value?.split('?')[0]?.trim();
|
const path = value?.split('?')[0]?.trim();
|
||||||
return path || '/';
|
return path || '/';
|
||||||
|
|||||||
+12
-1
@@ -5,8 +5,13 @@ import { AppModule } from './app.module';
|
|||||||
import { ApiEnvelopeInterceptor } from './common/http/api-envelope.interceptor';
|
import { ApiEnvelopeInterceptor } from './common/http/api-envelope.interceptor';
|
||||||
import { AllExceptionsFilter } from './common/http/all-exceptions.filter';
|
import { AllExceptionsFilter } from './common/http/all-exceptions.filter';
|
||||||
import { HttpRequestLoggingInterceptor } from './common/logging/http-request-logging.interceptor';
|
import { HttpRequestLoggingInterceptor } from './common/logging/http-request-logging.interceptor';
|
||||||
|
import { StructuredLoggerService } from './common/logging/structured-logger.service';
|
||||||
import { EnvConfig } from './config/env.schema';
|
import { EnvConfig } from './config/env.schema';
|
||||||
|
|
||||||
|
type RequestWithStartedAt = {
|
||||||
|
startedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
bufferLogs: true,
|
bufferLogs: true,
|
||||||
@@ -14,12 +19,18 @@ async function bootstrap(): Promise<void> {
|
|||||||
});
|
});
|
||||||
const config = app.get(ConfigService<EnvConfig, true>);
|
const config = app.get(ConfigService<EnvConfig, true>);
|
||||||
const corsOrigin = config.get('CORS_ORIGIN', { infer: 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({
|
app.enableCors({
|
||||||
origin: corsOrigin ? corsOrigin.split(',').map((origin) => origin.trim()) : true,
|
origin: corsOrigin ? corsOrigin.split(',').map((origin) => origin.trim()) : true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
app.useGlobalFilters(new AllExceptionsFilter());
|
app.useGlobalFilters(new AllExceptionsFilter(structuredLogger));
|
||||||
app.useGlobalInterceptors(
|
app.useGlobalInterceptors(
|
||||||
app.get(HttpRequestLoggingInterceptor),
|
app.get(HttpRequestLoggingInterceptor),
|
||||||
new ApiEnvelopeInterceptor(),
|
new ApiEnvelopeInterceptor(),
|
||||||
|
|||||||
Reference in New Issue
Block a user