feat(telemetry): collections, universal server-fn coverage, beacon, client tracker
- activities indexes + client_events/client_errors/rollups/alerts collections - global function middleware via createStart; reads skipped on success - actor stash, denial dedup, control-flow error guards - api route logging, auth event overrides, beacon endpoint, web vitals - centralized admin role checks, shared skeletons, award-badges fix - extracted deriveRunState with tests
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { pbFilter } from "../util/filter";
|
||||
import { likePattern } from "../util/like-pattern";
|
||||
|
||||
export type ClientErrorSource = "window" | "unhandledrejection" | "error-boundary" | "sw";
|
||||
|
||||
export interface ClientEventRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
player?: string | PlayerInfo;
|
||||
session_id?: string;
|
||||
path?: string;
|
||||
route_id?: string;
|
||||
value?: number;
|
||||
props?: any;
|
||||
user_agent?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface ClientEventInput {
|
||||
name: string;
|
||||
player?: string;
|
||||
session_id?: string;
|
||||
path?: string;
|
||||
route_id?: string;
|
||||
value?: number;
|
||||
props?: any;
|
||||
user_agent?: string;
|
||||
}
|
||||
|
||||
export interface ClientErrorRecord {
|
||||
id: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
source?: ClientErrorSource;
|
||||
path?: string;
|
||||
route_id?: string;
|
||||
group_hash: string;
|
||||
resolved: boolean;
|
||||
player?: string | PlayerInfo;
|
||||
session_id?: string;
|
||||
user_agent?: string;
|
||||
props?: any;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface ClientErrorInput {
|
||||
message: string;
|
||||
stack?: string;
|
||||
source?: ClientErrorSource;
|
||||
path?: string;
|
||||
route_id?: string;
|
||||
group_hash: string;
|
||||
resolved?: boolean;
|
||||
player?: string;
|
||||
session_id?: string;
|
||||
user_agent?: string;
|
||||
props?: any;
|
||||
}
|
||||
|
||||
export interface RollupRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
metric: string;
|
||||
dim: string;
|
||||
value: number;
|
||||
meta?: any;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface RollupInput {
|
||||
date: string;
|
||||
metric: string;
|
||||
dim: string;
|
||||
value: number;
|
||||
meta?: any;
|
||||
}
|
||||
|
||||
export interface TelemetryAlertRecord {
|
||||
id: string;
|
||||
kind: string;
|
||||
dim: string;
|
||||
message?: string;
|
||||
value?: number;
|
||||
meta?: any;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface TelemetryAlertInput {
|
||||
kind: string;
|
||||
dim: string;
|
||||
message?: string;
|
||||
value?: number;
|
||||
meta?: any;
|
||||
}
|
||||
|
||||
export interface TelemetryListResult<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface ClientEventSearchParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
name?: string;
|
||||
player?: string;
|
||||
sessionId?: string;
|
||||
path?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export interface ClientErrorSearchParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
groupHash?: string;
|
||||
resolved?: boolean;
|
||||
player?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return (error as { status?: number })?.status === 404;
|
||||
}
|
||||
|
||||
export function createTelemetryService(pb: PocketBase) {
|
||||
const service = {
|
||||
async createClientEvent(data: ClientEventInput): Promise<ClientEventRecord> {
|
||||
return pb.collection("client_events").create<ClientEventRecord>(data);
|
||||
},
|
||||
|
||||
async createClientError(data: ClientErrorInput): Promise<ClientErrorRecord> {
|
||||
return pb.collection("client_errors").create<ClientErrorRecord>(data);
|
||||
},
|
||||
|
||||
async searchClientEvents(
|
||||
params: ClientEventSearchParams = {}
|
||||
): Promise<TelemetryListResult<ClientEventRecord>> {
|
||||
const { page = 1, perPage = 100, name, player, sessionId, path, from, to, sortBy = "-created" } = params;
|
||||
|
||||
const filters: string[] = [];
|
||||
if (name) filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) }));
|
||||
if (player) filters.push(pbFilter(pb, "player = {:player}", { player }));
|
||||
if (sessionId) filters.push(pbFilter(pb, "session_id = {:sessionId}", { sessionId }));
|
||||
if (path) filters.push(pbFilter(pb, "path ~ {:path}", { path: likePattern(path) }));
|
||||
if (from) filters.push(pbFilter(pb, "created >= {:from}", { from }));
|
||||
if (to) filters.push(pbFilter(pb, "created < {:to}", { to }));
|
||||
|
||||
const result = await pb.collection("client_events").getList<ClientEventRecord>(page, perPage, {
|
||||
filter: filters.join(" && "),
|
||||
sort: sortBy,
|
||||
expand: "player",
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalPages: result.totalPages,
|
||||
totalItems: result.totalItems,
|
||||
};
|
||||
},
|
||||
|
||||
async searchClientErrors(
|
||||
params: ClientErrorSearchParams = {}
|
||||
): Promise<TelemetryListResult<ClientErrorRecord>> {
|
||||
const { page = 1, perPage = 100, groupHash, resolved, player, from, to, sortBy = "-created" } = params;
|
||||
|
||||
const filters: string[] = [];
|
||||
if (groupHash) filters.push(pbFilter(pb, "group_hash = {:groupHash}", { groupHash }));
|
||||
if (resolved !== undefined) filters.push(pbFilter(pb, "resolved = {:resolved}", { resolved }));
|
||||
if (player) filters.push(pbFilter(pb, "player = {:player}", { player }));
|
||||
if (from) filters.push(pbFilter(pb, "created >= {:from}", { from }));
|
||||
if (to) filters.push(pbFilter(pb, "created < {:to}", { to }));
|
||||
|
||||
const result = await pb.collection("client_errors").getList<ClientErrorRecord>(page, perPage, {
|
||||
filter: filters.join(" && "),
|
||||
sort: sortBy,
|
||||
expand: "player",
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalPages: result.totalPages,
|
||||
totalItems: result.totalItems,
|
||||
};
|
||||
},
|
||||
|
||||
// Idempotent by the (date, metric, dim) unique index; dim must be "" (never
|
||||
// null/undefined) or the index stops deduplicating.
|
||||
async upsertRollup(input: RollupInput): Promise<RollupRecord> {
|
||||
const data = { ...input, dim: input.dim ?? "" };
|
||||
const filter = pbFilter(pb, "date = {:date} && metric = {:metric} && dim = {:dim}", {
|
||||
date: data.date,
|
||||
metric: data.metric,
|
||||
dim: data.dim,
|
||||
});
|
||||
|
||||
try {
|
||||
const existing = await pb.collection("telemetry_rollups").getFirstListItem<RollupRecord>(filter);
|
||||
return await pb.collection("telemetry_rollups").update<RollupRecord>(existing.id, data);
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return await pb.collection("telemetry_rollups").create<RollupRecord>(data);
|
||||
} catch {
|
||||
const existing = await pb.collection("telemetry_rollups").getFirstListItem<RollupRecord>(filter);
|
||||
return pb.collection("telemetry_rollups").update<RollupRecord>(existing.id, data);
|
||||
}
|
||||
},
|
||||
|
||||
async getRollupRange(params: {
|
||||
metrics: string[];
|
||||
from: string;
|
||||
to: string;
|
||||
dim?: string;
|
||||
}): Promise<RollupRecord[]> {
|
||||
const { metrics, from, to, dim } = params;
|
||||
if (metrics.length === 0) return [];
|
||||
|
||||
const metricFilter = metrics
|
||||
.map((metric, i) => pbFilter(pb, `metric = {:m${i}}`, { [`m${i}`]: metric }))
|
||||
.join(" || ");
|
||||
const filters = [
|
||||
`(${metricFilter})`,
|
||||
pbFilter(pb, "date >= {:from}", { from }),
|
||||
pbFilter(pb, "date <= {:to}", { to }),
|
||||
];
|
||||
if (dim !== undefined) filters.push(pbFilter(pb, "dim = {:dim}", { dim }));
|
||||
|
||||
return pb.collection("telemetry_rollups").getFullList<RollupRecord>({
|
||||
filter: filters.join(" && "),
|
||||
sort: "date",
|
||||
});
|
||||
},
|
||||
|
||||
async createTelemetryAlert(data: TelemetryAlertInput): Promise<TelemetryAlertRecord> {
|
||||
return pb.collection("telemetry_alerts").create<TelemetryAlertRecord>({ ...data, dim: data.dim ?? "" });
|
||||
},
|
||||
|
||||
async findRecentAlert(kind: string, dim: string, sinceIso: string): Promise<TelemetryAlertRecord | null> {
|
||||
try {
|
||||
return await pb.collection("telemetry_alerts").getFirstListItem<TelemetryAlertRecord>(
|
||||
pbFilter(pb, "kind = {:kind} && dim = {:dim} && created >= {:since}", { kind, dim, since: sinceIso })
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async listTelemetryAlerts(page = 1, perPage = 50): Promise<TelemetryListResult<TelemetryAlertRecord>> {
|
||||
const result = await pb.collection("telemetry_alerts").getList<TelemetryAlertRecord>(page, perPage, {
|
||||
sort: "-created",
|
||||
});
|
||||
return {
|
||||
items: result.items,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalPages: result.totalPages,
|
||||
totalItems: result.totalItems,
|
||||
};
|
||||
},
|
||||
|
||||
// Bounded flip of a group's rows; new occurrences arrive resolved=false by
|
||||
// design, so a partial flip on a huge group is acceptable.
|
||||
async resolveErrorsByGroup(groupHash: string, resolved = true): Promise<number> {
|
||||
const MAX_PAGES = 25;
|
||||
let updated = 0;
|
||||
|
||||
for (let i = 0; i < MAX_PAGES; i++) {
|
||||
const batch = await pb.collection("client_errors").getList<ClientErrorRecord>(1, 200, {
|
||||
filter: pbFilter(pb, "group_hash = {:groupHash} && resolved != {:resolved}", { groupHash, resolved }),
|
||||
fields: "id",
|
||||
skipTotal: true,
|
||||
});
|
||||
if (batch.items.length === 0) break;
|
||||
for (const item of batch.items) {
|
||||
await pb.collection("client_errors").update(item.id, { resolved });
|
||||
updated++;
|
||||
}
|
||||
if (batch.items.length < 200) break;
|
||||
}
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
// Paged scan over a base collection (never SQL views). Stable created sort;
|
||||
// tight `fields` keeps payloads small for rollup passes.
|
||||
async pageCollection<T>(
|
||||
collection: string,
|
||||
opts: { filter?: string; fields?: string },
|
||||
cb: (items: T[]) => void
|
||||
): Promise<void> {
|
||||
const PER_PAGE = 500;
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await pb.collection(collection).getList<T>(page, PER_PAGE, {
|
||||
filter: opts.filter ?? "",
|
||||
fields: opts.fields,
|
||||
sort: "created",
|
||||
skipTotal: true,
|
||||
});
|
||||
if (result.items.length > 0) cb(result.items);
|
||||
if (result.items.length < PER_PAGE) break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
Reference in New Issue
Block a user