feat: 增加结构化请求日志

- src/common/logging: 新增 Pino 结构化日志服务和 HTTP 请求日志拦截器
- src/main.ts: 全局接入请求日志并保留响应 envelope
- package.json: 增加 pino 运行时依赖
This commit is contained in:
湛兮
2026-06-12 05:40:18 +08:00
parent dea6cfd7fe
commit e420968d5f
6 changed files with 300 additions and 1 deletions
+3
View File
@@ -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
View File
@@ -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')