Files
flxn-app/src/lib/telemetry/rollup-core.ts
T
kyle f593b2c7b8 feat(telemetry): product interaction events
- 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)
2026-08-26 23:04:36 -07:00

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;
};