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:
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@dukang/client-logging",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { getSessionId } from './session';
|
||||
|
||||
export type TrackParams = Record<string, unknown>;
|
||||
|
||||
export type UserTrackerOptions = {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getToken?: () => string | null;
|
||||
};
|
||||
|
||||
export function createUserTracker(opts: UserTrackerOptions) {
|
||||
const getToken = opts.getToken ?? (() => localStorage.getItem('accessToken'));
|
||||
|
||||
function track(eventName: string, params?: TrackParams) {
|
||||
const sessionId = getSessionId();
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': opts.clientApp,
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
void fetch(`${opts.apiBase}/analytics/events`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
clientApp: opts.clientApp,
|
||||
events: [{ eventName, params: { sessionId, ...params } }],
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function trackPageView(eventName: string, params?: TrackParams) {
|
||||
track(eventName, {
|
||||
pagePath: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
function trackSessionStart() {
|
||||
track('session_start', {
|
||||
pagePath: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { track, trackPageView, trackSessionStart };
|
||||
}
|
||||
|
||||
export type StoreTrackerOptions = {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getToken: () => string | null;
|
||||
getStoreId: () => string | null;
|
||||
};
|
||||
|
||||
export function createStoreTracker(opts: StoreTrackerOptions) {
|
||||
function track(eventName: string, params?: TrackParams) {
|
||||
const token = opts.getToken();
|
||||
const storeId = opts.getStoreId();
|
||||
if (!token || !storeId) return;
|
||||
|
||||
void fetch(`${opts.apiBase}/analytics/store-events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-App': opts.clientApp,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: getSessionId(),
|
||||
storeId,
|
||||
events: [{ eventName, params: { sessionId: getSessionId(), storeId, ...params } }],
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function trackPageView(eventName: string, params?: TrackParams) {
|
||||
track(eventName, {
|
||||
pagePath: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
return { track, trackPageView };
|
||||
}
|
||||
|
||||
export type PartnerTrackerOptions = {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getToken: () => string | null;
|
||||
};
|
||||
|
||||
export function createPartnerTracker(opts: PartnerTrackerOptions) {
|
||||
function track(eventName: string, params?: TrackParams) {
|
||||
const token = opts.getToken();
|
||||
if (!token) return;
|
||||
|
||||
void fetch(`${opts.apiBase}/analytics/partner-events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-App': opts.clientApp,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: getSessionId(),
|
||||
events: [{ eventName, params: { sessionId: getSessionId(), ...params } }],
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function trackPageView(eventName: string, params?: TrackParams) {
|
||||
track(eventName, {
|
||||
pagePath: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
return { track, trackPageView };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export type ClientErrorLevel = 'fatal' | 'error' | 'warn' | 'info';
|
||||
export type ClientErrorCategory =
|
||||
| 'js_error'
|
||||
| 'unhandled_rejection'
|
||||
| 'api_error'
|
||||
| 'network'
|
||||
| 'render'
|
||||
| 'bridge'
|
||||
| 'other';
|
||||
|
||||
export type ClientErrorReporterOptions = {
|
||||
apiBase: string;
|
||||
clientApp: string;
|
||||
getToken?: () => string | null;
|
||||
};
|
||||
|
||||
export function installClientErrorReporting(opts: ClientErrorReporterOptions) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const report = (payload: {
|
||||
level: ClientErrorLevel;
|
||||
category: ClientErrorCategory;
|
||||
message: string;
|
||||
stack?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}) => {
|
||||
const token = opts.getToken?.() ?? localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': opts.clientApp,
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
void fetch(`${opts.apiBase}/common/client-errors`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
level: payload.level,
|
||||
category: payload.category,
|
||||
message: payload.message,
|
||||
stack: payload.stack,
|
||||
pagePath: window.location.pathname,
|
||||
clientApp: opts.clientApp,
|
||||
extra: payload.extra,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
window.addEventListener('error', (ev) => {
|
||||
report({
|
||||
level: 'error',
|
||||
category: 'js_error',
|
||||
message: ev.message || 'Unknown error',
|
||||
stack: ev.error instanceof Error ? ev.error.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (ev) => {
|
||||
const reason = ev.reason;
|
||||
report({
|
||||
level: 'error',
|
||||
category: 'unhandled_rejection',
|
||||
message: reason instanceof Error ? reason.message : String(reason),
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function reportApiError(
|
||||
opts: ClientErrorReporterOptions,
|
||||
input: { message: string; status?: number; url?: string; category?: ClientErrorCategory },
|
||||
) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const token = opts.getToken?.() ?? localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': opts.clientApp,
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
void fetch(`${opts.apiBase}/common/client-errors`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
level: 'warn',
|
||||
category: input.category ?? 'api_error',
|
||||
message: input.message.slice(0, 1000),
|
||||
pagePath: window.location.pathname,
|
||||
clientApp: opts.clientApp,
|
||||
extra: { status: input.status, url: input.url },
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export { getSessionId, touchSession } from './session';
|
||||
export {
|
||||
createUserTracker,
|
||||
createStoreTracker,
|
||||
createPartnerTracker,
|
||||
type TrackParams,
|
||||
type UserTrackerOptions,
|
||||
type StoreTrackerOptions,
|
||||
type PartnerTrackerOptions,
|
||||
} from './analytics';
|
||||
export {
|
||||
installClientErrorReporting,
|
||||
reportApiError,
|
||||
type ClientErrorLevel,
|
||||
type ClientErrorCategory,
|
||||
type ClientErrorReporterOptions,
|
||||
} from './client-error';
|
||||
@@ -0,0 +1,18 @@
|
||||
const SESSION_KEY = 'dukang_session_id';
|
||||
|
||||
export function getSessionId(): string {
|
||||
if (typeof localStorage === 'undefined') return 'ssr';
|
||||
let id = localStorage.getItem(SESSION_KEY);
|
||||
if (!id) {
|
||||
id =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `s_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
localStorage.setItem(SESSION_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function touchSession(): string {
|
||||
return getSessionId();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../domain/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export type PartnerLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'page_view'
|
||||
| 'account_ops'
|
||||
| 'store_ops'
|
||||
| 'shipping'
|
||||
@@ -10,6 +11,20 @@ export type PartnerLogCategory =
|
||||
export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly string[]> = {
|
||||
login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'],
|
||||
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
|
||||
page_view: [
|
||||
'partner_home_view',
|
||||
'partner_store_list_view',
|
||||
'partner_store_detail_view',
|
||||
'partner_store_create_view',
|
||||
'partner_order_list_view',
|
||||
'partner_order_detail_view',
|
||||
'partner_proxy_order_view',
|
||||
'partner_bills_view',
|
||||
'partner_bill_detail_view',
|
||||
'partner_staff_list_view',
|
||||
'partner_leaderboard_view',
|
||||
'partner_weekly_report_view',
|
||||
],
|
||||
account_ops: [
|
||||
'partner_staff_create',
|
||||
'partner_staff_sms_send',
|
||||
@@ -24,7 +39,7 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly s
|
||||
'partner_store_audit_approved',
|
||||
'partner_store_audit_rejected',
|
||||
],
|
||||
shipping: ['partner_order_ship', 'partner_delivery_advance'],
|
||||
shipping: ['partner_order_ship', 'partner_delivery_advance', 'partner_proxy_order_create'],
|
||||
settlement: ['partner_bill_view', 'partner_bill_detail_view', 'partner_bill_confirm'],
|
||||
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
|
||||
};
|
||||
@@ -33,6 +48,7 @@ export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | '
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'page_view', label: '页面浏览' },
|
||||
{ value: 'account_ops', label: '子账号管理' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
{ value: 'shipping', label: '发货/配送' },
|
||||
@@ -44,6 +60,7 @@ export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '微信授权',
|
||||
page_view: '页面浏览',
|
||||
account_ops: '子账号管理',
|
||||
store_ops: '门店操作',
|
||||
shipping: '发货/配送',
|
||||
@@ -82,3 +99,13 @@ export interface PartnerLogRowDto {
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type PartnerAnalyticsExtra = {
|
||||
sessionId?: string;
|
||||
partnerAccountId?: string;
|
||||
storeId?: string;
|
||||
orderId?: string;
|
||||
billId?: string;
|
||||
cityCode?: string;
|
||||
pagePath?: string;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
export type StoreLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'page_view'
|
||||
| 'redeem'
|
||||
| 'payout'
|
||||
| 'store_ops';
|
||||
| 'store_ops'
|
||||
| 'package';
|
||||
|
||||
export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly string[]> = {
|
||||
login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'],
|
||||
login: [
|
||||
'store_sms_send',
|
||||
'store_sms_login',
|
||||
'store_sms_verify_fail',
|
||||
'store_login_success',
|
||||
'store_select',
|
||||
],
|
||||
wechat_auth: ['store_wechat_login', 'store_wechat_bind'],
|
||||
page_view: [
|
||||
'store_home_view',
|
||||
'store_redeem_entry_view',
|
||||
'store_redeem_confirm_view',
|
||||
'store_phone_redeem_view',
|
||||
'store_records_view',
|
||||
'store_withdraw_view',
|
||||
'store_packages_view',
|
||||
'store_mine_view',
|
||||
'store_staff_view',
|
||||
],
|
||||
redeem: [
|
||||
'store_redeem_preview',
|
||||
'store_redeem_confirm',
|
||||
@@ -19,27 +38,39 @@ export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly strin
|
||||
'store_redeem_phone_lookup_sms',
|
||||
'store_redeem_phone_balance',
|
||||
'store_redeem_phone_prepare',
|
||||
'store_redeem_scan_start',
|
||||
],
|
||||
payout: ['store_payout_created', 'store_payout_paid'],
|
||||
store_ops: ['store_status_change'],
|
||||
payout: ['store_payout_created', 'store_payout_paid', 'store_withdraw_applied', 'store_withdraw_paid'],
|
||||
store_ops: [
|
||||
'store_status_change',
|
||||
'store_staff_create',
|
||||
'store_staff_update',
|
||||
'store_staff_delete',
|
||||
'store_staff_permission_update',
|
||||
],
|
||||
package: ['store_package_apply', 'store_package_audit_view'],
|
||||
};
|
||||
|
||||
export const STORE_LOG_CATEGORY_OPTIONS: Array<{ value: StoreLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '授权' },
|
||||
{ value: 'page_view', label: '页面浏览' },
|
||||
{ value: 'redeem', label: '核销' },
|
||||
{ value: 'payout', label: '提现/打款' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
{ value: 'package', label: '套餐' },
|
||||
];
|
||||
|
||||
export const STORE_LOG_CATEGORY_LABELS: Record<StoreLogCategory | '', string> = {
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '授权',
|
||||
page_view: '页面浏览',
|
||||
redeem: '核销',
|
||||
payout: '提现/打款',
|
||||
store_ops: '门店操作',
|
||||
package: '套餐',
|
||||
};
|
||||
|
||||
export function resolveStoreLogCategory(eventName: string): StoreLogCategory | null {
|
||||
@@ -72,3 +103,14 @@ export interface StoreLogRowDto {
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type StoreAnalyticsExtra = {
|
||||
sessionId?: string;
|
||||
storeId?: string;
|
||||
storeAccountId?: string;
|
||||
redeemChannel?: 'scan' | 'phone' | 'pending';
|
||||
amount?: number;
|
||||
failReason?: string;
|
||||
durationMs?: number;
|
||||
pagePath?: string;
|
||||
};
|
||||
|
||||
@@ -6,17 +6,39 @@ export type UserLogCategory =
|
||||
| 'wechat_auth'
|
||||
| 'browse_store'
|
||||
| 'redeem'
|
||||
| 'profile';
|
||||
| 'profile'
|
||||
| 'promo'
|
||||
| 'customer_service'
|
||||
| 'after_sale'
|
||||
| 'error';
|
||||
|
||||
export const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
||||
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
|
||||
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
||||
order: ['order_confirm_view', 'order_submit'],
|
||||
pay: ['pay_success', 'pay_fail'],
|
||||
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
||||
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail', 'session_start'],
|
||||
browse_product: ['home_view', 'product_list_view', 'product_click', 'product_detail_view'],
|
||||
order: ['order_confirm_view', 'order_submit', 'order_list_view', 'order_detail_view', 'order_cancel'],
|
||||
pay: ['pay_page_view', 'pay_success', 'pay_fail'],
|
||||
wechat_auth: [
|
||||
'wechat_login',
|
||||
'wechat_phone',
|
||||
'wechat_phone_login',
|
||||
'wechat_location',
|
||||
'wechat_album',
|
||||
'wechat_bind',
|
||||
],
|
||||
browse_store: ['store_list_view', 'store_detail_view'],
|
||||
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
||||
profile: ['profile_update', 'bind_phone'],
|
||||
redeem: [
|
||||
'benefit_page_view',
|
||||
'benefit_detail_view',
|
||||
'benefit_redeem_start',
|
||||
'benefit_redeem_success',
|
||||
'redeem_code_view',
|
||||
'redeem_success_view',
|
||||
],
|
||||
profile: ['profile_update', 'bind_phone', 'address_list_view', 'address_edit'],
|
||||
promo: ['promo_touch', 'promo_share'],
|
||||
customer_service: ['cs_contact'],
|
||||
after_sale: ['after_sale_list_view', 'after_sale_apply'],
|
||||
error: ['client_error'],
|
||||
};
|
||||
|
||||
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
||||
@@ -29,6 +51,10 @@ export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; lab
|
||||
{ value: 'browse_store', label: '浏览门店' },
|
||||
{ value: 'redeem', label: '核销' },
|
||||
{ value: 'profile', label: '信息修改' },
|
||||
{ value: 'promo', label: '推广' },
|
||||
{ value: 'customer_service', label: '客服' },
|
||||
{ value: 'after_sale', label: '售后' },
|
||||
{ value: 'error', label: '前端异常' },
|
||||
];
|
||||
|
||||
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
|
||||
@@ -59,3 +85,19 @@ export interface UserLogRowDto {
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** C 端埋点 extraJson 推荐字段(画像分析) */
|
||||
export type UserAnalyticsExtra = {
|
||||
sessionId?: string;
|
||||
cityCode?: string;
|
||||
productId?: string;
|
||||
skuId?: string;
|
||||
storeId?: string;
|
||||
orderId?: string;
|
||||
amount?: number;
|
||||
quantity?: number;
|
||||
sourceType?: string;
|
||||
sourceRefId?: string;
|
||||
failReason?: string;
|
||||
pagePath?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user