feat(analytics): persona logging upgrade and Sentry system config
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,8 +5,9 @@ import {
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { Observable, catchError, tap, throwError } from 'rxjs';
|
||||
import type { AuthUser } from '../guards/jwt-auth.guard';
|
||||
import type { RequestWithId } from '../logging/request-id.middleware';
|
||||
import { HQ_OPERATION_KEY, type HqOperationMeta } from './hq-operation.decorator';
|
||||
import { HqOperationLogService } from './hq-operation-log.service';
|
||||
|
||||
@@ -61,42 +62,51 @@ export class HqOperationInterceptor implements NestInterceptor {
|
||||
);
|
||||
if (!meta) return next.handle();
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const req = context.switchToHttp().getRequest<RequestWithId & { user?: AuthUser }>();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user || user.actorType !== 'HQ') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const writeLog = (status: 'SUCCESS' | 'FAILED', data?: unknown, errorMessage?: string) => {
|
||||
const refId = meta.batch
|
||||
? 0n
|
||||
: pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
|
||||
?? pickRefId(
|
||||
meta.refIdField
|
||||
? (data as Record<string, unknown> | null)?.[meta.refIdField]
|
||||
: (data as Record<string, unknown> | null)?.id,
|
||||
)
|
||||
?? 0n;
|
||||
|
||||
const detail: Record<string, unknown> = {
|
||||
method: req.method,
|
||||
path: req.originalUrl ?? req.url,
|
||||
requestId: req.requestId,
|
||||
};
|
||||
if (meta.includeBody && req.body) {
|
||||
detail.requestBody = sanitizeBody(req.body);
|
||||
}
|
||||
if (status === 'SUCCESS' && meta.includeResponse !== false && data != null) {
|
||||
detail.response = summarizeResponse(data);
|
||||
}
|
||||
if (errorMessage) detail.error = errorMessage;
|
||||
|
||||
this.logService.logSafe({
|
||||
hqAccountId: user.actorId,
|
||||
action: meta.action,
|
||||
refType: meta.refType,
|
||||
refId,
|
||||
status,
|
||||
detail,
|
||||
});
|
||||
};
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((data) => {
|
||||
const refId = meta.batch
|
||||
? 0n
|
||||
: pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
|
||||
?? pickRefId(
|
||||
meta.refIdField
|
||||
? (data as Record<string, unknown> | null)?.[meta.refIdField]
|
||||
: (data as Record<string, unknown> | null)?.id,
|
||||
)
|
||||
?? 0n;
|
||||
|
||||
const detail: Record<string, unknown> = {
|
||||
method: req.method,
|
||||
path: req.originalUrl ?? req.url,
|
||||
};
|
||||
if (meta.includeBody && req.body) {
|
||||
detail.requestBody = sanitizeBody(req.body);
|
||||
}
|
||||
if (meta.includeResponse !== false && data != null) {
|
||||
detail.response = summarizeResponse(data);
|
||||
}
|
||||
|
||||
this.logService.logSafe({
|
||||
hqAccountId: user.actorId,
|
||||
action: meta.action,
|
||||
refType: meta.refType,
|
||||
refId,
|
||||
detail,
|
||||
});
|
||||
tap((data) => writeLog('SUCCESS', data)),
|
||||
catchError((err: { message?: string }) => {
|
||||
writeLog('FAILED', undefined, err?.message ?? '操作失败');
|
||||
return throwError(() => err);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthUser } from '../guards/jwt-auth.guard';
|
||||
import type { RequestWithId } from './request-id.middleware';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const started = Date.now();
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<RequestWithId & { user?: AuthUser }>();
|
||||
const res = http.getResponse<Response>();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => this.logLine(req, res.statusCode, Date.now() - started),
|
||||
error: (err: { status?: number; message?: string }) => {
|
||||
const status = err?.status ?? res.statusCode ?? 500;
|
||||
this.logLine(req, status, Date.now() - started, err?.message);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private logLine(
|
||||
req: RequestWithId & { user?: AuthUser },
|
||||
status: number,
|
||||
latencyMs: number,
|
||||
error?: string,
|
||||
) {
|
||||
const line = {
|
||||
requestId: req.requestId,
|
||||
method: req.method,
|
||||
path: req.originalUrl || req.url,
|
||||
status,
|
||||
latencyMs,
|
||||
clientApp: req.headers['x-client-app'],
|
||||
actorType: req.user?.actorType,
|
||||
actorId: req.user?.actorId != null ? String(req.user.actorId) : undefined,
|
||||
error: error?.slice(0, 200),
|
||||
};
|
||||
if (status >= 500) this.logger.error(JSON.stringify(line));
|
||||
else if (status >= 400) this.logger.warn(JSON.stringify(line));
|
||||
else this.logger.log(JSON.stringify(line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RequestIdMiddleware } from './request-id.middleware';
|
||||
import { LoggingInterceptor } from './logging.interceptor';
|
||||
|
||||
@Module({
|
||||
providers: [RequestIdMiddleware, LoggingInterceptor],
|
||||
exports: [RequestIdMiddleware, LoggingInterceptor],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
export type RequestWithId = Request & { requestId?: string };
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: RequestWithId, res: Response, next: NextFunction) {
|
||||
const incoming = req.headers[REQUEST_ID_HEADER];
|
||||
const requestId =
|
||||
typeof incoming === 'string' && incoming.trim()
|
||||
? incoming.trim().slice(0, 64)
|
||||
: randomUUID();
|
||||
req.requestId = requestId;
|
||||
res.setHeader('X-Request-Id', requestId);
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
|
||||
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
|
||||
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
|
||||
{
|
||||
key: 'SENTRY_DSN',
|
||||
label: 'Sentry DSN',
|
||||
group: G.deploy,
|
||||
type: 'password',
|
||||
secret: true,
|
||||
requiresRestart: true,
|
||||
description: '后端错误聚合;留空不启用。配置后需重启 API 生效',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'WINERY_BANK_ACCOUNT_NAME',
|
||||
|
||||
Reference in New Issue
Block a user