- h2h_viewed from match card, dock, profile, stats, predictions - reaction_sent with emoji and surface; score_report_opened from all three report entry points - stats_filtered and stats_sorted on stats route and profile - rules/teams/players/bracket/tournament view events with sources - match_history_viewed, badge_viewed, player_searched (count only)
199 lines
6.1 KiB
TypeScript
199 lines
6.1 KiB
TypeScript
import type { RollupInput } from "@/lib/pocketbase/services/telemetry";
|
|
|
|
export interface ActivityRow {
|
|
name: string;
|
|
player?: string;
|
|
duration?: number;
|
|
success?: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export interface ClientEventRow {
|
|
name: string;
|
|
player?: string;
|
|
session_id?: string;
|
|
route_id?: string;
|
|
value?: number;
|
|
props?: { source?: string; emoji?: string; tournament?: string; value?: string } | null;
|
|
}
|
|
|
|
export interface ClientErrorRow {
|
|
group_hash: string;
|
|
}
|
|
|
|
export const percentile = (values: number[], p: number): number => {
|
|
if (values.length === 0) return 0;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const rank = Math.ceil((p / 100) * sorted.length);
|
|
return sorted[Math.min(sorted.length, Math.max(1, rank)) - 1];
|
|
};
|
|
|
|
export interface ActivityAccumulator {
|
|
players: Set<string>;
|
|
perFn: Map<string, { count: number; errors: number; durations: number[] }>;
|
|
denied: number;
|
|
otpSent: number;
|
|
otpFailed: number;
|
|
}
|
|
|
|
export const newActivityAccumulator = (): ActivityAccumulator => ({
|
|
players: new Set(),
|
|
perFn: new Map(),
|
|
denied: 0,
|
|
otpSent: 0,
|
|
otpFailed: 0,
|
|
});
|
|
|
|
export const accumulateActivities = (acc: ActivityAccumulator, rows: ActivityRow[]) => {
|
|
for (const row of rows) {
|
|
if (row.player) acc.players.add(row.player);
|
|
if (row.error?.startsWith("FORBIDDEN")) acc.denied++;
|
|
if (row.name === "auth.otp_sent") acc.otpSent++;
|
|
if (row.name === "auth.otp_failed") acc.otpFailed++;
|
|
if (row.name.startsWith("auth.")) continue;
|
|
|
|
const fn = acc.perFn.get(row.name) ?? { count: 0, errors: 0, durations: [] };
|
|
fn.count++;
|
|
if (row.success === false) fn.errors++;
|
|
if (typeof row.duration === "number" && row.duration > 0) fn.durations.push(row.duration);
|
|
acc.perFn.set(row.name, fn);
|
|
}
|
|
};
|
|
|
|
export interface ClientEventAccumulator {
|
|
players: Set<string>;
|
|
sessions: Set<string>;
|
|
pageViewsByRoute: Map<string, number>;
|
|
vitals: Map<string, number[]>;
|
|
eventCounts: Map<string, number>;
|
|
eventDetails: Map<string, number>;
|
|
}
|
|
|
|
export const newClientEventAccumulator = (): ClientEventAccumulator => ({
|
|
players: new Set(),
|
|
sessions: new Set(),
|
|
pageViewsByRoute: new Map(),
|
|
vitals: new Map(),
|
|
eventCounts: new Map(),
|
|
eventDetails: new Map(),
|
|
});
|
|
|
|
// emoji first: reaction_sent carries both emoji and source, and its aggregate
|
|
// breakdown should be by emoji.
|
|
const eventDetail = (row: ClientEventRow): string | undefined =>
|
|
row.props?.emoji ?? row.props?.value ?? row.props?.source ?? row.props?.tournament;
|
|
|
|
export const accumulateClientEvents = (acc: ClientEventAccumulator, rows: ClientEventRow[]) => {
|
|
for (const row of rows) {
|
|
if (row.player) acc.players.add(row.player);
|
|
if (row.session_id) acc.sessions.add(row.session_id);
|
|
|
|
if (row.name === "page_view") {
|
|
const route = row.route_id || "(unknown)";
|
|
acc.pageViewsByRoute.set(route, (acc.pageViewsByRoute.get(route) ?? 0) + 1);
|
|
} else if (row.name.startsWith("vital.") && typeof row.value === "number") {
|
|
const metric = row.name.slice("vital.".length);
|
|
const values = acc.vitals.get(metric) ?? [];
|
|
values.push(row.value);
|
|
acc.vitals.set(metric, values);
|
|
} else {
|
|
acc.eventCounts.set(row.name, (acc.eventCounts.get(row.name) ?? 0) + 1);
|
|
const detail = eventDetail(row);
|
|
if (detail) {
|
|
const key = `${row.name}|${detail}`;
|
|
acc.eventDetails.set(key, (acc.eventDetails.get(key) ?? 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
export interface ClientErrorAccumulator {
|
|
byGroup: Map<string, number>;
|
|
total: number;
|
|
}
|
|
|
|
export const newClientErrorAccumulator = (): ClientErrorAccumulator => ({
|
|
byGroup: new Map(),
|
|
total: 0,
|
|
});
|
|
|
|
export const accumulateClientErrors = (acc: ClientErrorAccumulator, rows: ClientErrorRow[]) => {
|
|
for (const row of rows) {
|
|
acc.total++;
|
|
acc.byGroup.set(row.group_hash, (acc.byGroup.get(row.group_hash) ?? 0) + 1);
|
|
}
|
|
};
|
|
|
|
export const buildDayRollups = (
|
|
date: string,
|
|
input: {
|
|
activities: ActivityAccumulator;
|
|
clientEvents: ClientEventAccumulator;
|
|
clientErrors: ClientErrorAccumulator;
|
|
wau?: number;
|
|
mau?: number;
|
|
partial: boolean;
|
|
}
|
|
): RollupInput[] => {
|
|
const { activities, clientEvents, clientErrors, wau, mau, partial } = input;
|
|
const meta = partial ? { partial: true } : undefined;
|
|
const rollups: RollupInput[] = [];
|
|
const push = (metric: string, dim: string, value: number, extraMeta?: Record<string, unknown>) => {
|
|
rollups.push({
|
|
date,
|
|
metric,
|
|
dim,
|
|
value,
|
|
meta: extraMeta ? { ...meta, ...extraMeta } : meta,
|
|
});
|
|
};
|
|
|
|
const dayPlayers = new Set([...activities.players, ...clientEvents.players]);
|
|
push("dau", "", dayPlayers.size);
|
|
if (wau !== undefined) push("wau", "", wau);
|
|
if (mau !== undefined) push("mau", "", mau);
|
|
|
|
for (const [name, fn] of activities.perFn) {
|
|
push("server_fn.count", name, fn.count);
|
|
if (fn.errors > 0) push("server_fn.error_count", name, fn.errors);
|
|
if (fn.durations.length > 0) {
|
|
push("server_fn.duration.p50", name, percentile(fn.durations, 50), {
|
|
samples: fn.durations.length,
|
|
});
|
|
push("server_fn.duration.p95", name, percentile(fn.durations, 95), {
|
|
samples: fn.durations.length,
|
|
});
|
|
}
|
|
}
|
|
|
|
let totalPageViews = 0;
|
|
for (const [route, count] of clientEvents.pageViewsByRoute) {
|
|
push("page_view.count", route, count);
|
|
totalPageViews += count;
|
|
}
|
|
push("page_view.count", "", totalPageViews);
|
|
push("page_view.sessions", "", clientEvents.sessions.size);
|
|
|
|
for (const [name, count] of clientEvents.eventCounts) {
|
|
push("event.count", name, count);
|
|
}
|
|
for (const [key, count] of clientEvents.eventDetails) {
|
|
push("event.detail.count", key, count);
|
|
}
|
|
|
|
push("client_error.count", "", clientErrors.total);
|
|
for (const [group, count] of clientErrors.byGroup) {
|
|
push("client_error.count", group, count);
|
|
}
|
|
|
|
for (const [metric, values] of clientEvents.vitals) {
|
|
push("vital.p75", metric, percentile(values, 75), { samples: values.length });
|
|
}
|
|
|
|
push("denied.count", "", activities.denied);
|
|
push("auth.otp_sent.count", "", activities.otpSent);
|
|
push("auth.otp_failed.count", "", activities.otpFailed);
|
|
|
|
return rollups;
|
|
};
|