feat: 增加结构化请求日志
- src/common/logging: 新增 Pino 结构化日志服务和 HTTP 请求日志拦截器 - src/main.ts: 全局接入请求日志并保留响应 envelope - package.json: 增加 pino 运行时依赖
This commit is contained in:
@@ -17,6 +17,8 @@ import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProjectsModule } from './projects/projects.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { WeComModule } from './notifications/wecom/wecom.module';
|
||||
import { HttpRequestLoggingInterceptor } from './common/logging/http-request-logging.interceptor';
|
||||
import { StructuredLoggerService } from './common/logging/structured-logger.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -43,5 +45,6 @@ import { WeComModule } from './notifications/wecom/wecom.module';
|
||||
ProjectsModule,
|
||||
DeployRunsModule,
|
||||
],
|
||||
providers: [StructuredLoggerService, HttpRequestLoggingInterceptor],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 全局 HTTP 日志拦截器记录请求结果、耗时和操作者,不记录 body/header/token。
|
||||
*/
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, catchError, tap, throwError } 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';
|
||||
|
||||
type RequestWithLoggingContext = {
|
||||
headers?: RequestHeaders;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
ip?: string;
|
||||
socket?: {
|
||||
remoteAddress?: string;
|
||||
};
|
||||
user?: AuthenticatedUser;
|
||||
};
|
||||
|
||||
type ResponseWithStatus = {
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class HttpRequestLoggingInterceptor implements NestInterceptor {
|
||||
constructor(private readonly logger: StructuredLoggerService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
if (context.getType() !== 'http') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const httpContext = context.switchToHttp();
|
||||
const request = httpContext.getRequest<RequestWithLoggingContext>();
|
||||
const response = httpContext.getResponse<ResponseWithStatus>();
|
||||
const startedAt = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
this.logRequest('success', request, response, startedAt);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logRequest('error', request, response, startedAt, error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private logRequest(
|
||||
outcome: 'success' | 'error',
|
||||
request: RequestWithLoggingContext,
|
||||
response: ResponseWithStatus,
|
||||
startedAt: number,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const statusCode = this.resolveStatusCode(response, error);
|
||||
const payload = {
|
||||
event: 'http_request_completed',
|
||||
outcome,
|
||||
requestId: resolveRequestId(request.headers),
|
||||
method: request.method,
|
||||
path: this.safePath(request.originalUrl ?? request.url),
|
||||
statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
actorId: request.user?.id,
|
||||
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 || '/';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Pino 结构化日志服务只输出运维可索引字段,避免业务层直接 console。
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import pino, { Logger } from 'pino';
|
||||
import { EnvConfig } from '../../config/env.schema';
|
||||
|
||||
export type StructuredLogPayload = Record<string, unknown>;
|
||||
|
||||
@Injectable()
|
||||
export class StructuredLoggerService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(private readonly config: ConfigService<EnvConfig, true>) {
|
||||
this.logger = pino({
|
||||
name: 'devops-platform-api',
|
||||
base: {
|
||||
service: 'devops-platform-api',
|
||||
env: this.config.get('NODE_ENV', { infer: true }),
|
||||
},
|
||||
level: this.config.get('NODE_ENV', { infer: true }) === 'test' ? 'silent' : 'info',
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
});
|
||||
}
|
||||
|
||||
info(payload: StructuredLogPayload, message: string): void {
|
||||
this.logger.info(payload, message);
|
||||
}
|
||||
|
||||
warn(payload: StructuredLogPayload, message: string): void {
|
||||
this.logger.warn(payload, message);
|
||||
}
|
||||
|
||||
error(payload: StructuredLogPayload, message: string): void {
|
||||
this.logger.error(payload, message);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -4,6 +4,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
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 { EnvConfig } from './config/env.schema';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
@@ -19,7 +20,10 @@ async function bootstrap(): Promise<void> {
|
||||
credentials: true,
|
||||
});
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
app.useGlobalInterceptors(new ApiEnvelopeInterceptor());
|
||||
app.useGlobalInterceptors(
|
||||
app.get(HttpRequestLoggingInterceptor),
|
||||
new ApiEnvelopeInterceptor(),
|
||||
);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('运维平台 API')
|
||||
|
||||
Reference in New Issue
Block a user