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 { 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; sessionId?: 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 { return pb.collection("client_events").create(data); }, async createClientError(data: ClientErrorInput): Promise { return pb.collection("client_errors").create(data); }, async searchClientEvents( params: ClientEventSearchParams = {} ): Promise> { 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(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> { const { page = 1, perPage = 100, groupHash, resolved, player, sessionId, 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 (sessionId) filters.push(pbFilter(pb, "session_id = {:sessionId}", { sessionId })); 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(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, }; }, // dim must be "" (never null/undefined) or the unique index stops deduplicating. async upsertRollup(input: RollupInput): Promise { 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(filter); return await pb.collection("telemetry_rollups").update(existing.id, data); } catch (error) { if (!isNotFound(error)) throw error; } try { return await pb.collection("telemetry_rollups").create(data); } catch { const existing = await pb.collection("telemetry_rollups").getFirstListItem(filter); return pb.collection("telemetry_rollups").update(existing.id, data); } }, async getRollupRange(params: { metrics: string[]; from: string; to: string; dim?: string; }): Promise { 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({ filter: filters.join(" && "), sort: "date", }); }, async createTelemetryAlert(data: TelemetryAlertInput): Promise { return pb.collection("telemetry_alerts").create({ ...data, dim: data.dim ?? "" }); }, async findRecentAlert(kind: string, dim: string, sinceIso: string): Promise { try { return await pb.collection("telemetry_alerts").getFirstListItem( 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> { const result = await pb.collection("telemetry_alerts").getList(page, perPage, { sort: "-created", }); return { items: result.items, page: result.page, perPage: result.perPage, totalPages: result.totalPages, totalItems: result.totalItems, }; }, async resolveErrorsByGroup(groupHash: string, resolved = true): Promise { const MAX_PAGES = 25; let updated = 0; for (let i = 0; i < MAX_PAGES; i++) { const batch = await pb.collection("client_errors").getList(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; }, async pageCollection( collection: string, opts: { filter?: string; fields?: string }, cb: (items: T[]) => void ): Promise { const PER_PAGE = 500; for (let page = 1; ; page++) { const result = await pb.collection(collection).getList(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; }