feat(ops): add client error reporting API and frontend hooks
Collect mini-user/shop/partner JS errors via POST /common/client-errors, persist logs, and push fatal/error to WeCom. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Headers, Post, UseGuards } from '@nestjs/common';
|
||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { ClientErrorService } from './client-error.service';
|
||||
import { ReportClientErrorDto } from './dto/client-error.dto';
|
||||
|
||||
@Controller('common')
|
||||
export class ClientErrorController {
|
||||
constructor(private readonly clientErrors: ClientErrorService) {}
|
||||
|
||||
/**
|
||||
* 客户端报错上报(可匿名)。
|
||||
* fatal/error → Nest 日志 + 落库 + 企微;warn → 仅日志/落库。
|
||||
*/
|
||||
@Post('client-errors')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
report(
|
||||
@Body() dto: ReportClientErrorDto,
|
||||
@CurrentUser() user: AuthUser | undefined,
|
||||
@Headers('x-client-app') clientApp?: string,
|
||||
) {
|
||||
return this.clientErrors.report(dto, user, clientApp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { ClientApp, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { AlertService } from '../../common/alert/alert.service';
|
||||
import type { AlertLevel } from '../../common/alert/alert.constants';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import type { ReportClientErrorDto } from './dto/client-error.dto';
|
||||
|
||||
const WECOM_LEVELS = new Set(['fatal', 'error']);
|
||||
|
||||
@Injectable()
|
||||
export class ClientErrorService {
|
||||
private readonly logger = new Logger(ClientErrorService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) {
|
||||
const clientApp = (dto.clientApp || headerClientApp || user?.clientApp || 'UNKNOWN').slice(0, 32);
|
||||
const message = dto.message.trim().slice(0, 1000);
|
||||
const stack = dto.stack?.trim().slice(0, 4000);
|
||||
const pagePath = dto.pagePath?.trim().slice(0, 128);
|
||||
const fingerprint = `${dto.level}|${dto.category}|${message.slice(0, 120)}`;
|
||||
|
||||
const logLine = {
|
||||
level: dto.level,
|
||||
category: dto.category,
|
||||
message,
|
||||
pagePath,
|
||||
clientApp,
|
||||
actorType: user?.actorType,
|
||||
actorId: user?.actorId != null ? String(user.actorId) : undefined,
|
||||
stack: stack?.slice(0, 800),
|
||||
extra: dto.extra,
|
||||
};
|
||||
|
||||
if (dto.level === 'fatal') {
|
||||
this.logger.error(`[client_error] ${JSON.stringify(logLine)}`);
|
||||
} else if (dto.level === 'error') {
|
||||
this.logger.error(`[client_error] ${JSON.stringify(logLine)}`);
|
||||
} else {
|
||||
this.logger.warn(`[client_error] ${JSON.stringify(logLine)}`);
|
||||
}
|
||||
|
||||
// 落库便于 HQ 排查(匿名也可写,userId 为空)
|
||||
try {
|
||||
await this.prisma.logUserAnalytics.create({
|
||||
data: {
|
||||
userId:
|
||||
user?.actorType === 'USER' && user.actorId != null ? user.actorId : null,
|
||||
eventName: 'client_error',
|
||||
clientApp: isClientApp(clientApp) ? clientApp : null,
|
||||
pagePath: pagePath || null,
|
||||
extraJson: {
|
||||
level: dto.level,
|
||||
category: dto.category,
|
||||
message,
|
||||
stack: stack || null,
|
||||
actorType: user?.actorType ?? null,
|
||||
actorId: user?.actorId != null ? String(user.actorId) : null,
|
||||
storeId: user?.storeId != null ? String(user.storeId) : null,
|
||||
...(dto.extra && typeof dto.extra === 'object' ? { clientExtra: dto.extra } : {}),
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`client_error persist failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (WECOM_LEVELS.has(dto.level)) {
|
||||
const alertLevel: AlertLevel = dto.level === 'fatal' ? 'P0' : 'P1';
|
||||
this.alert.notify({
|
||||
level: alertLevel,
|
||||
category: 'client_error',
|
||||
title: `客户端报错 [${dto.level}/${dto.category}]`,
|
||||
detail: [
|
||||
`端:${clientApp}`,
|
||||
pagePath ? `页面:${pagePath}` : null,
|
||||
user?.actorId != null
|
||||
? `用户:${user.actorType}:${String(user.actorId)}`
|
||||
: '用户:匿名',
|
||||
`消息:${message}`,
|
||||
stack ? `堆栈:${stack.slice(0, 600)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
dedupeKey: `client_error|${fingerprint}`,
|
||||
dedupeTtlSec: 600,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
function isClientApp(v: string): v is ClientApp {
|
||||
return [
|
||||
'USER_MINI',
|
||||
'USER_H5',
|
||||
'PARTNER_MINI',
|
||||
'PARTNER_H5',
|
||||
'HQ_MINI',
|
||||
'HQ_WEB',
|
||||
'SHOP_H5',
|
||||
].includes(v);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { WechatController } from './wechat.controller';
|
||||
import { ClientConfigController } from './client-config.controller';
|
||||
import { LbsController } from './lbs.controller';
|
||||
import { WechatLocationService } from './wechat-location.service';
|
||||
import { ClientErrorService } from './client-error.service';
|
||||
import { ClientErrorController } from './client-error.controller';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
|
||||
@@ -27,6 +29,7 @@ import { WechatLocationService } from './wechat-location.service';
|
||||
WechatController,
|
||||
ClientConfigController,
|
||||
LbsController,
|
||||
ClientErrorController,
|
||||
],
|
||||
providers: [
|
||||
ResourceService,
|
||||
@@ -35,6 +38,7 @@ import { WechatLocationService } from './wechat-location.service';
|
||||
SupportTicketService,
|
||||
ThirdPartyLogService,
|
||||
WechatLocationService,
|
||||
ClientErrorService,
|
||||
],
|
||||
exports: [ResourceService, EventService, TicketService, SupportTicketService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** 客户端报错严重级别 */
|
||||
export const CLIENT_ERROR_LEVELS = ['fatal', 'error', 'warn'] as const;
|
||||
export type ClientErrorLevel = (typeof CLIENT_ERROR_LEVELS)[number];
|
||||
|
||||
/** 客户端报错类别 */
|
||||
export const CLIENT_ERROR_CATEGORIES = [
|
||||
'js_error',
|
||||
'unhandled_rejection',
|
||||
'api_error',
|
||||
'network',
|
||||
'render',
|
||||
'bridge',
|
||||
'other',
|
||||
] as const;
|
||||
export type ClientErrorCategory = (typeof CLIENT_ERROR_CATEGORIES)[number];
|
||||
|
||||
export class ReportClientErrorDto {
|
||||
@IsIn(CLIENT_ERROR_LEVELS)
|
||||
level!: ClientErrorLevel;
|
||||
|
||||
@IsIn(CLIENT_ERROR_CATEGORIES)
|
||||
category!: ClientErrorCategory;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
message!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
stack?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
pagePath?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
clientApp?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user