diff --git a/package.json b/package.json index 667eeb6..8109710 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed8f931..8032854 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/app.module.ts b/src/app.module.ts index 626b12d..801d341 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/common/logging/http-request-logging.interceptor.ts b/src/common/logging/http-request-logging.interceptor.ts new file mode 100644 index 0000000..80e814e --- /dev/null +++ b/src/common/logging/http-request-logging.interceptor.ts @@ -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 { + if (context.getType() !== 'http') { + return next.handle(); + } + + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + const response = httpContext.getResponse(); + 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 { + 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 || '/'; + } +} diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts new file mode 100644 index 0000000..16717f0 --- /dev/null +++ b/src/common/logging/structured-logger.service.ts @@ -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; + +@Injectable() +export class StructuredLoggerService { + private readonly logger: Logger; + + constructor(private readonly config: ConfigService) { + 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); + } +} diff --git a/src/main.ts b/src/main.ts index 51c1ce7..6c119f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { @@ -19,7 +20,10 @@ async function bootstrap(): Promise { credentials: true, }); app.useGlobalFilters(new AllExceptionsFilter()); - app.useGlobalInterceptors(new ApiEnvelopeInterceptor()); + app.useGlobalInterceptors( + app.get(HttpRequestLoggingInterceptor), + new ApiEnvelopeInterceptor(), + ); const swaggerConfig = new DocumentBuilder() .setTitle('运维平台 API')