增加性能统计

This commit is contained in:
2026-04-15 11:46:42 +08:00
parent 20ae332706
commit 6bb80d7c8c
5 changed files with 343 additions and 20 deletions
+22
View File
@@ -65,6 +65,13 @@ export interface DashboardStat {
hint: string;
}
export interface DashboardPerformanceMetric {
label: string;
valueMs: number | null;
sampleCount: number;
hint: string;
}
export interface DashboardTrendPoint {
bucket: string;
total: number;
@@ -85,10 +92,23 @@ export interface DashboardRecentEvent {
pathname: string | null;
component: string | null;
elementText: string | null;
value: string | null;
durationMs: number | null;
occurredAt: string;
metadata: JsonValue | null;
}
export interface DashboardConversationItem {
id: string;
sessionKey: string;
role: string;
text: string;
mode: string | null;
turnIndex: number | null;
durationMs: number | null;
occurredAt: string;
}
export interface DashboardRecentSession {
sessionKey: string;
platform: AnalyticsPlatform;
@@ -101,10 +121,12 @@ export interface DashboardRecentSession {
export interface DashboardData {
stats: DashboardStat[];
performanceMetrics: DashboardPerformanceMetric[];
activityTrend: DashboardTrendPoint[];
topEvents: DashboardTopItem[];
topPaths: DashboardTopItem[];
recentEvents: DashboardRecentEvent[];
recentConversations: DashboardConversationItem[];
recentSessions: DashboardRecentSession[];
}
+20 -1
View File
@@ -1,5 +1,6 @@
const EVENT_LABELS: Record<string, string> = {
screen_view: "打开页面",
digital_human_init_ready: "数字人准备完成",
voice_clone_entry_tap: "进入音色克隆",
avatar_selected: "选择数字人形象",
call_page_opened: "进入陪伴通话",
@@ -30,7 +31,7 @@ const EVENT_LABELS: Record<string, string> = {
ai_conversation_error: "陪伴通话异常",
user_transcript_received: "收到用户语音文本",
assistant_transcript_completed: "陪伴回复已生成",
assistant_response_done: "陪伴回复结束",
assistant_response_done: "整轮回复完成",
ai_conversation_stopped: "陪伴通话已结束",
clone_tts_started: "开始播放专属音色",
clone_tts_finished: "专属音色播放完成",
@@ -38,6 +39,9 @@ const EVENT_LABELS: Record<string, string> = {
camera_enabled: "打开摄像头",
camera_disabled: "关闭摄像头",
camera_capture_error: "摄像头采集失败",
camera_first_frame_ready: "摄像头画面就绪",
user_message_uploaded: "用户聊天记录已上传",
assistant_message_uploaded: "助手聊天记录已上传",
};
const PATH_LABELS: Record<string, string> = {
@@ -79,9 +83,24 @@ export function describeAnalyticsType(type: string) {
return "页面浏览";
case "interaction":
return "操作记录";
case "performance":
return "性能指标";
case "conversation":
return "聊天记录";
case "error":
return "异常提醒";
default:
return humanizeFallback(type);
}
}
export function describeConversationRole(role: string | null | undefined) {
switch ((role ?? "").toLowerCase()) {
case "assistant":
return "助手回复";
case "user":
return "用户发言";
default:
return "对话记录";
}
}
+189 -16
View File
@@ -12,6 +12,7 @@ import type {
} from "@/lib/analytics/contracts";
import { ANALYTICS_RANGE_OPTIONS } from "@/lib/analytics/contracts";
import {
describeConversationRole,
describeAnalyticsEventName,
describeAnalyticsPath,
describeAnalyticsType,
@@ -165,6 +166,14 @@ function countResult(rows: Array<{ total: bigint | number | string | null | unde
return Number(total ?? 0);
}
function averageResult(rows: Array<{ average: number | null; total: bigint | number | string | null | undefined }>) {
const average = rows[0]?.average;
return {
average: average == null ? null : Number(average),
total: Number(rows[0]?.total ?? 0),
};
}
function formatBucket(bucket: Date, days: number) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
@@ -188,6 +197,43 @@ function buildDeviceLabel(session: {
return browser || "未标记设备";
}
function readMetadataObject(metadata: unknown) {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return null;
}
return metadata as Record<string, JsonValue>;
}
function readMetadataString(metadata: unknown, key: string) {
const object = readMetadataObject(metadata);
const value = object?.[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readMetadataNumber(metadata: unknown, key: string) {
const object = readMetadataObject(metadata);
const value = object?.[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function compactText(value: string, max = 220) {
const normalized = value.trim().replace(/\s+/g, " ");
if (!normalized) {
return "";
}
return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized;
}
export function parseDashboardQuery(raw: { range?: string }): DashboardQuery {
const parsedDays = Number(raw.range ?? 7);
const days = ANALYTICS_RANGE_OPTIONS.includes(parsedDays as (typeof ANALYTICS_RANGE_OPTIONS)[number])
@@ -303,6 +349,7 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
const rangeStart = new Date(now.getTime() - query.days * 24 * 60 * 60 * 1000);
const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart);
const eventFilter = buildRangeFilter("occurredAt", rangeStart);
const usageEventFilter = `${eventFilter} AND lower("type") NOT IN ('conversation', 'performance')`;
const sessionWhere = {
lastSeenAt: { gte: rangeStart },
platform: "ANDROID" as const,
@@ -315,13 +362,18 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
const [
sessionCountRows,
visitorCountRows,
eventCountRows,
errorCountRows,
responseDurationRows,
connectDurationRows,
assistantTextDurationRows,
assistantFirstAudioRows,
cloneCreateDurationRows,
activityTrendRows,
topEventRows,
topPathRows,
recentSessions,
recentEvents,
recentConversations,
] = await Promise.all([
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
@@ -333,11 +385,6 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
FROM "AnalyticsSession"
WHERE ${sessionFilter}
`),
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
`),
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
@@ -348,11 +395,50 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
OR lower("name") LIKE '%fail%'
)
`),
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
SELECT AVG("durationMs")::double precision AS average,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
AND "name" = 'assistant_response_done'
AND "durationMs" IS NOT NULL
`),
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
SELECT AVG("durationMs")::double precision AS average,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
AND "name" = 'ai_conversation_connected'
AND "durationMs" IS NOT NULL
`),
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
SELECT AVG("durationMs")::double precision AS average,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
AND "name" = 'assistant_message_uploaded'
AND "durationMs" IS NOT NULL
`),
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
SELECT AVG(NULLIF("metadata"->>'firstAudioLatencyMs', '')::double precision) AS average,
COUNT(*) FILTER (WHERE NULLIF("metadata"->>'firstAudioLatencyMs', '') IS NOT NULL)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
AND "name" = 'assistant_response_done'
`),
prisma.$queryRawUnsafe<Array<{ average: number | null; total: bigint }>>(`
SELECT AVG("durationMs")::double precision AS average,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
AND "name" = 'voice_clone_create_succeeded'
AND "durationMs" IS NOT NULL
`),
prisma.$queryRawUnsafe<Array<{ bucket: Date; total: bigint }>>(`
SELECT date_trunc('${query.days <= 2 ? "hour" : "day"}', "occurredAt") AS bucket,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
WHERE ${usageEventFilter}
GROUP BY 1
ORDER BY 1 ASC
`),
@@ -361,7 +447,7 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
MIN("type") AS detail,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
WHERE ${usageEventFilter}
GROUP BY "name"
ORDER BY total DESC
LIMIT 8
@@ -370,7 +456,7 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
SELECT COALESCE(NULLIF("pathname", ''), '(未标记页面)') AS label,
COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
WHERE ${usageEventFilter}
GROUP BY 1
ORDER BY total DESC
LIMIT 8
@@ -393,7 +479,10 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
},
}),
prisma.analyticsEvent.findMany({
where: eventWhere,
where: {
...eventWhere,
type: { not: "conversation" },
},
orderBy: { occurredAt: "desc" },
take: 18,
select: {
@@ -405,6 +494,26 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
pathname: true,
component: true,
elementText: true,
value: true,
durationMs: true,
occurredAt: true,
metadata: true,
},
}),
prisma.analyticsEvent.findMany({
where: {
...eventWhere,
type: "conversation",
},
orderBy: { occurredAt: "desc" },
take: 12,
select: {
id: true,
sessionKey: true,
name: true,
elementText: true,
value: true,
durationMs: true,
occurredAt: true,
metadata: true,
},
@@ -413,8 +522,12 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
const sessionCount = countResult(sessionCountRows);
const uniqueVisitors = countResult(visitorCountRows);
const eventCount = countResult(eventCountRows);
const errorCount = countResult(errorCountRows);
const responseDuration = averageResult(responseDurationRows);
const connectDuration = averageResult(connectDurationRows);
const assistantTextDuration = averageResult(assistantTextDurationRows);
const assistantFirstAudio = averageResult(assistantFirstAudioRows);
const cloneCreateDuration = averageResult(cloneCreateDurationRows);
return {
stats: [
@@ -429,9 +542,12 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
hint: "按安装标识或会话编号去重后的实际使用主体",
},
{
label: "互动记录",
value: eventCount,
hint: sessionCount > 0 ? `平均每台设备 ${(eventCount / sessionCount).toFixed(1)} 条` : "当前还没有数据",
label: "平均回复耗时(ms)",
value: responseDuration.average == null ? 0 : Math.round(responseDuration.average),
hint:
responseDuration.total > 0
? `基于 ${responseDuration.total} 次已完成回复,从老人说完到整轮回复结束`
: "还没有可计算的回复耗时",
},
{
label: "异常提醒",
@@ -439,6 +555,32 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
hint: "连接、播放、录音和资源准备失败都会在这里体现",
},
],
performanceMetrics: [
{
label: "连接成功",
valueMs: connectDuration.average,
sampleCount: connectDuration.total,
hint: "从点击开始对话到建立实时连接",
},
{
label: "文字回复完成",
valueMs: assistantTextDuration.average,
sampleCount: assistantTextDuration.total,
hint: "从老人停顿到拿到完整文字回复",
},
{
label: "开始出声",
valueMs: assistantFirstAudio.average,
sampleCount: assistantFirstAudio.total,
hint: "标准陪伴通话从停顿到第一段声音到达",
},
{
label: "专属音色生成",
valueMs: cloneCreateDuration.average,
sampleCount: cloneCreateDuration.total,
hint: "从提交录音到拿到可用专属音色",
},
],
activityTrend: activityTrendRows.map((row: { bucket: Date; total: bigint | number | string }) => ({
bucket: formatBucket(row.bucket, query.days),
total: Number(row.total),
@@ -461,6 +603,8 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
pathname: string | null;
component: string | null;
elementText: string | null;
value: string | null;
durationMs: number | null;
occurredAt: Date;
metadata: unknown;
}) => ({
@@ -472,9 +616,37 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
pathname: describeAnalyticsPath(event.pathname),
component: event.component,
elementText: event.elementText,
value: event.value,
durationMs: event.durationMs,
occurredAt: event.occurredAt.toISOString(),
metadata: (event.metadata ?? null) as DashboardData["recentEvents"][number]["metadata"],
})),
recentConversations: recentConversations.map((event: {
id: string;
sessionKey: string;
name: string;
elementText: string | null;
value: string | null;
durationMs: number | null;
occurredAt: Date;
metadata: unknown;
}) => {
const role = readMetadataString(event.metadata, "role") ?? (event.name.startsWith("assistant") ? "assistant" : "user");
const fullText = readMetadataString(event.metadata, "text") ?? event.elementText ?? event.value ?? "";
const mode = readMetadataString(event.metadata, "mode");
const turnIndex = readMetadataNumber(event.metadata, "turnIndex");
return {
id: event.id,
sessionKey: event.sessionKey,
role: describeConversationRole(role),
text: compactText(fullText),
mode,
turnIndex: turnIndex == null ? null : Math.round(turnIndex),
durationMs: event.durationMs,
occurredAt: event.occurredAt.toISOString(),
};
}),
recentSessions: recentSessions.map((session: {
sessionKey: string;
platform: AnalyticsPlatform;
@@ -501,12 +673,13 @@ export async function getDashboardData(query: DashboardQuery): Promise<Dashboard
export async function getPublicAnalyticsSnapshot(): Promise<PublicAnalyticsSnapshot> {
const rangeStart = new Date(Date.now() - 24 * 60 * 60 * 1000);
const eventFilter = buildRangeFilter("occurredAt", rangeStart);
const usageEventFilter = `${eventFilter} AND lower("type") NOT IN ('conversation', 'performance')`;
const sessionFilter = buildRangeFilter("lastSeenAt", rangeStart);
const [eventRows, sessionRows, topActionRows] = await Promise.all([
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
WHERE ${usageEventFilter}
`),
prisma.$queryRawUnsafe<Array<{ total: bigint }>>(`
SELECT COUNT(*)::bigint AS total
@@ -516,7 +689,7 @@ export async function getPublicAnalyticsSnapshot(): Promise<PublicAnalyticsSnaps
prisma.$queryRawUnsafe<Array<{ label: string; total: bigint }>>(`
SELECT "name" AS label, COUNT(*)::bigint AS total
FROM "AnalyticsEvent"
WHERE ${eventFilter}
WHERE ${usageEventFilter}
GROUP BY 1
ORDER BY total DESC
LIMIT 1
+20
View File
@@ -15,4 +15,24 @@ export function formatDateTime(value: string | Date) {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
export function formatDurationMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) {
return "暂无";
}
if (value < 1000) {
return `${Math.round(value)} ms`;
}
const seconds = value / 1000;
if (seconds < 60) {
const rounded = seconds >= 10 ? seconds.toFixed(1) : seconds.toFixed(2);
return `${rounded.replace(/\.0+$/, "")} s`;
}
const minutes = Math.floor(seconds / 60);
const remainSeconds = Math.round(seconds % 60);
return `${minutes} 分 ${remainSeconds} 秒`;
}