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
+1
View File
@@ -30,6 +30,7 @@
"@nestjs/platform-express": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@prisma/client": "^6.14.0",
"pino": "^10.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"zod": "^4.0.17"
+98
View File
@@ -26,6 +26,9 @@ importers:
'@prisma/client':
specifier: ^6.14.0
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
pino:
specifier: ^10.3.1
version: 10.3.1
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -741,6 +744,9 @@ packages:
'@nestjs/platform-express':
optional: true
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1197,6 +1203,10 @@ packages:
array-timsort@1.0.3:
resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
babel-jest@30.4.1:
resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -2333,6 +2343,10 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -2431,6 +2445,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pino-abstract-transport@3.0.0:
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
pino-std-serializers@7.1.0:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
pino@10.3.1:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
@@ -2469,6 +2493,9 @@ packages:
typescript:
optional: true
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -2487,6 +2514,9 @@ packages:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -2512,6 +2542,13 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -2552,6 +2589,10 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -2618,6 +2659,9 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
source-map-support@0.5.13:
resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==}
@@ -2632,6 +2676,10 @@ packages:
resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -2765,6 +2813,10 @@ packages:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
@@ -3848,6 +3900,8 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.26(@nestjs/common@11.1.26(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.26)
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -4323,6 +4377,8 @@ snapshots:
array-timsort@1.0.3: {}
atomic-sleep@1.0.0: {}
babel-jest@30.4.1(@babel/core@7.29.7):
dependencies:
'@babel/core': 7.29.7
@@ -5628,6 +5684,8 @@ snapshots:
ohash@2.0.11: {}
on-exit-leak-free@2.1.2: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -5724,6 +5782,26 @@ snapshots:
picomatch@4.0.4: {}
pino-abstract-transport@3.0.0:
dependencies:
split2: 4.2.0
pino-std-serializers@7.1.0: {}
pino@10.3.1:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pino-std-serializers: 7.1.0
process-warning: 5.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.1
thread-stream: 4.2.0
pirates@4.0.7: {}
pkg-dir@4.2.0:
@@ -5758,6 +5836,8 @@ snapshots:
transitivePeerDependencies:
- magicast
process-warning@5.0.0: {}
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -5773,6 +5853,8 @@ snapshots:
dependencies:
side-channel: 1.1.1
quick-format-unescaped@4.0.4: {}
range-parser@1.2.1: {}
raw-body@3.0.2:
@@ -5799,6 +5881,10 @@ snapshots:
readdirp@4.1.2: {}
real-require@0.2.0: {}
real-require@1.0.0: {}
reflect-metadata@0.2.2: {}
require-directory@2.1.1: {}
@@ -5838,6 +5924,8 @@ snapshots:
safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
schema-utils@3.3.0:
@@ -5924,6 +6012,10 @@ snapshots:
slash@3.0.0: {}
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
source-map-support@0.5.13:
dependencies:
buffer-from: 1.1.2
@@ -5938,6 +6030,8 @@ snapshots:
source-map@0.7.4: {}
split2@4.2.0: {}
sprintf-js@1.0.3: {}
stack-utils@2.0.6:
@@ -6030,6 +6124,10 @@ snapshots:
glob: 7.2.3
minimatch: 3.1.5
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
tinyexec@1.2.4: {}
tinyglobby@0.2.17:
+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')