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:
@@ -9,6 +9,7 @@ import { createBadgesService } from "./services/badges";
|
||||
import { createGroupsService } from "./services/groups";
|
||||
import { createPredictionsService } from "./services/predictions";
|
||||
import { createPushService } from "./services/push";
|
||||
import { createTelemetryService } from "./services/telemetry";
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
@@ -67,6 +68,7 @@ class PocketBaseAdminClient {
|
||||
Object.assign(this, createGroupsService(this.pb));
|
||||
Object.assign(this, createPredictionsService(this.pb));
|
||||
Object.assign(this, createPushService(this.pb));
|
||||
Object.assign(this, createTelemetryService(this.pb));
|
||||
|
||||
this.authPromise = this.authenticate();
|
||||
this.authPromise
|
||||
@@ -156,7 +158,8 @@ interface AdminClient
|
||||
ReturnType<typeof createBadgesService>,
|
||||
ReturnType<typeof createGroupsService>,
|
||||
ReturnType<typeof createPredictionsService>,
|
||||
ReturnType<typeof createPushService> {
|
||||
ReturnType<typeof createPushService>,
|
||||
ReturnType<typeof createTelemetryService> {
|
||||
authPromise: Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
||||
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
||||
import { Prediction } from "@/features/predictions/types";
|
||||
import { Activity } from "../services/activities";
|
||||
import { ClientEventRecord, ClientErrorRecord } from "../services/telemetry";
|
||||
|
||||
export function transformPlayerInfo(record: any): PlayerInfo {
|
||||
return {
|
||||
@@ -368,3 +369,38 @@ export function transformActivity(record: any): Activity {
|
||||
updated: record.updated,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformClientEvent(record: any): ClientEventRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
player: record.expand?.player ? transformPlayerInfo(record.expand.player) : record.player,
|
||||
session_id: record.session_id,
|
||||
path: record.path,
|
||||
route_id: record.route_id,
|
||||
value: record.value,
|
||||
props: record.props,
|
||||
user_agent: record.user_agent,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformClientError(record: any): ClientErrorRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
message: record.message,
|
||||
stack: record.stack,
|
||||
source: record.source,
|
||||
path: record.path,
|
||||
route_id: record.route_id,
|
||||
group_hash: record.group_hash,
|
||||
resolved: !!record.resolved,
|
||||
player: record.expand?.player ? transformPlayerInfo(record.expand.player) : record.player,
|
||||
session_id: record.session_id,
|
||||
user_agent: record.user_agent,
|
||||
props: record.props,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
import { withPasswordlessTelemetry } from "@/lib/telemetry/auth-events.server";
|
||||
import { logger } from "../";
|
||||
|
||||
const init = () =>
|
||||
@@ -25,6 +26,9 @@ const init = () =>
|
||||
};
|
||||
},
|
||||
},
|
||||
override: {
|
||||
functions: withPasswordlessTelemetry(),
|
||||
},
|
||||
});
|
||||
|
||||
export default { init };
|
||||
|
||||
@@ -4,6 +4,7 @@ import SuperTokens from "supertokens-node";
|
||||
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
||||
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
|
||||
import { resolveLocale } from "@/lib/i18n";
|
||||
import { withPasswordlessTelemetry } from "@/lib/telemetry/auth-events.server";
|
||||
|
||||
// The user isn't authenticated yet when the login code is sent, so the SMS
|
||||
// locale comes from the stored preference of whichever user owns the phone
|
||||
@@ -51,10 +52,10 @@ const init = () =>
|
||||
},
|
||||
},
|
||||
override: {
|
||||
functions: (originalImplementation) => {
|
||||
functions: withPasswordlessTelemetry((originalImplementation) => {
|
||||
return {
|
||||
...originalImplementation,
|
||||
consumeCode: async (input) => {
|
||||
consumeCode: async (input: any) => {
|
||||
const session = await useSession({
|
||||
password: input.preAuthSessionId,
|
||||
});
|
||||
@@ -84,7 +85,7 @@ const init = () =>
|
||||
return response;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import UserRoles from "supertokens-node/recipe/userroles";
|
||||
import { appInfo } from "./config";
|
||||
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
|
||||
import PasswordlessTwilioVerify from "./recipes/passwordless-twilio-verify";
|
||||
import { withSessionTelemetry } from "@/lib/telemetry/auth-events.server";
|
||||
import { logger } from "./";
|
||||
import type { TypeInput } from "supertokens-node/types";
|
||||
|
||||
@@ -30,6 +31,10 @@ export const backendConfig = (): TypeInput => {
|
||||
|
||||
// Debug only
|
||||
exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production",
|
||||
|
||||
override: {
|
||||
functions: withSessionTelemetry(),
|
||||
},
|
||||
}),
|
||||
Dashboard.init(),
|
||||
UserRoles.init(),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { recordActivity } from "@/utils/activities";
|
||||
import { getRequestActor } from "./request-context.server";
|
||||
|
||||
// Request-type counterpart of serverFnLoggingMiddleware for createFileRoute
|
||||
// API handlers, which the global function middleware never sees. Not for the
|
||||
// SSE route (a long-lived stream is not a request-shaped unit) or /api/health.
|
||||
export const apiLoggingMiddleware = createMiddleware({
|
||||
type: "request",
|
||||
}).server(async ({ next, request }) => {
|
||||
const name = `api:${new URL(request.url).pathname}`;
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await next();
|
||||
const status = result.response?.status ?? 200;
|
||||
|
||||
recordActivity({
|
||||
name,
|
||||
player: getRequestActor(request)?.playerId,
|
||||
duration: Date.now() - startTime,
|
||||
success: status < 400,
|
||||
error: status >= 400 ? `HTTP ${status}` : undefined,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Response) throw error;
|
||||
|
||||
recordActivity({
|
||||
name,
|
||||
player: getRequestActor(request)?.playerId,
|
||||
duration: Date.now() - startTime,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
user_agent: userAgent,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
|
||||
const logger = new Logger("Telemetry");
|
||||
|
||||
interface AuthEventInput {
|
||||
authUserId?: string;
|
||||
phone?: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Fire-and-forget: auth flows must never fail or slow down because telemetry
|
||||
// did. The phone number is used only to resolve the player, never stored.
|
||||
export const recordAuthEvent = (name: string, input: AuthEventInput) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
await pbAdmin.authPromise;
|
||||
|
||||
let playerId: string | undefined;
|
||||
let authUserId = input.authUserId;
|
||||
|
||||
if (!authUserId && input.phone) {
|
||||
try {
|
||||
const SuperTokens = (await import("supertokens-node")).default;
|
||||
const users = await SuperTokens.listUsersByAccountInfo("public", {
|
||||
phoneNumber: input.phone,
|
||||
});
|
||||
authUserId = users[0]?.id;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (authUserId) {
|
||||
try {
|
||||
const player = await pbAdmin.getPlayerByAuthId(authUserId);
|
||||
playerId = player?.id;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await pbAdmin.createActivity({
|
||||
name,
|
||||
player: playerId,
|
||||
duration: 0,
|
||||
success: input.success,
|
||||
error: input.error,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to record auth event", error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
type PasswordlessOverride = (originalImplementation: any) => any;
|
||||
|
||||
// Composes with a recipe's existing override.functions so telemetry wraps the
|
||||
// final implementation (Twilio verify included).
|
||||
export const withPasswordlessTelemetry =
|
||||
(existing?: PasswordlessOverride): PasswordlessOverride =>
|
||||
(originalImplementation: any) => {
|
||||
const base = existing ? existing(originalImplementation) : originalImplementation;
|
||||
|
||||
return {
|
||||
...base,
|
||||
createCode: async (input: any) => {
|
||||
try {
|
||||
const response = await base.createCode(input);
|
||||
recordAuthEvent("auth.otp_sent", {
|
||||
phone: input.phoneNumber,
|
||||
success: response.status === "OK",
|
||||
error: response.status === "OK" ? undefined : response.status,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
recordAuthEvent("auth.otp_sent", {
|
||||
phone: input.phoneNumber,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
consumeCode: async (input: any) => {
|
||||
try {
|
||||
const response = await base.consumeCode(input);
|
||||
if (response.status === "OK") {
|
||||
recordAuthEvent("auth.otp_consumed", {
|
||||
authUserId: response.user?.id,
|
||||
success: true,
|
||||
});
|
||||
} else {
|
||||
recordAuthEvent("auth.otp_failed", {
|
||||
success: false,
|
||||
error: response.status,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
recordAuthEvent("auth.otp_failed", {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const withSessionTelemetry =
|
||||
(): ((originalImplementation: any) => any) =>
|
||||
(originalImplementation: any) => ({
|
||||
...originalImplementation,
|
||||
refreshSession: async (input: any) => {
|
||||
const session = await originalImplementation.refreshSession(input);
|
||||
recordAuthEvent("auth.session_refresh", {
|
||||
authUserId: session?.getUserId?.(),
|
||||
success: true,
|
||||
});
|
||||
return session;
|
||||
},
|
||||
revokeSession: async (input: any) => {
|
||||
const result = await originalImplementation.revokeSession(input);
|
||||
recordAuthEvent("auth.logout", { success: true });
|
||||
return result;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beaconPayloadSchema, MAX_BEACON_EVENTS } from "./beacon-schema";
|
||||
|
||||
const validSession = { sessionId: "12345678-session" };
|
||||
|
||||
describe("beaconPayloadSchema", () => {
|
||||
it("accepts a mixed batch of valid events", () => {
|
||||
const result = beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [
|
||||
{ kind: "event", name: "page_view", path: "/tournaments", routeId: "/_authed/tournaments/" },
|
||||
{ kind: "vital", metric: "LCP", value: 1234.5, rating: "good" },
|
||||
{ kind: "error", message: "boom", source: "window", stack: "Error: boom\n at x" },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown event kinds", () => {
|
||||
const result = beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [{ kind: "pixel", name: "x" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty and oversized batches", () => {
|
||||
expect(beaconPayloadSchema.safeParse({ ...validSession, events: [] }).success).toBe(false);
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: Array.from({ length: MAX_BEACON_EVENTS + 1 }, () => ({
|
||||
kind: "event",
|
||||
name: "x",
|
||||
})),
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects short session ids", () => {
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
sessionId: "short",
|
||||
events: [{ kind: "event", name: "x" }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces string length caps", () => {
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [{ kind: "error", message: "x".repeat(2001), source: "window" }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [{ kind: "event", name: "x".repeat(65) }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid vital metrics", () => {
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [{ kind: "vital", metric: "FID", value: 1 }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import z from "zod";
|
||||
|
||||
export const MAX_BEACON_EVENTS = 50;
|
||||
export const MAX_BEACON_BYTES = 64 * 1024;
|
||||
|
||||
export const WEB_VITAL_METRICS = ["LCP", "CLS", "INP", "FCP", "TTFB"] as const;
|
||||
|
||||
const commonLocation = {
|
||||
path: z.string().max(512).optional(),
|
||||
routeId: z.string().max(256).optional(),
|
||||
};
|
||||
|
||||
const trackEventSchema = z.object({
|
||||
kind: z.literal("event"),
|
||||
name: z.string().min(1).max(64),
|
||||
value: z.number().finite().optional(),
|
||||
props: z.unknown().optional(),
|
||||
ts: z.number().optional(),
|
||||
...commonLocation,
|
||||
});
|
||||
|
||||
const errorEventSchema = z.object({
|
||||
kind: z.literal("error"),
|
||||
message: z.string().min(1).max(2000),
|
||||
stack: z.string().max(8000).optional(),
|
||||
source: z.enum(["window", "unhandledrejection", "error-boundary", "sw"]),
|
||||
props: z.unknown().optional(),
|
||||
...commonLocation,
|
||||
});
|
||||
|
||||
const vitalEventSchema = z.object({
|
||||
kind: z.literal("vital"),
|
||||
metric: z.enum(WEB_VITAL_METRICS),
|
||||
value: z.number().finite(),
|
||||
rating: z.enum(["good", "needs-improvement", "poor"]).optional(),
|
||||
...commonLocation,
|
||||
});
|
||||
|
||||
export const beaconPayloadSchema = z.object({
|
||||
sessionId: z.string().min(8).max(64),
|
||||
events: z
|
||||
.array(z.discriminatedUnion("kind", [trackEventSchema, errorEventSchema, vitalEventSchema]))
|
||||
.min(1)
|
||||
.max(MAX_BEACON_EVENTS),
|
||||
});
|
||||
|
||||
export type BeaconPayload = z.infer<typeof beaconPayloadSchema>;
|
||||
export type BeaconEvent = BeaconPayload["events"][number];
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { AnyRouter } from "@tanstack/react-router";
|
||||
|
||||
// Provider-free by design: renders nothing and mounts no React context, so it
|
||||
// cannot disturb the LinguiProvider -> AuthProvider order. Browser-only; every
|
||||
// entry point is a no-op during SSR and in dev unless VITE_TELEMETRY_DEV=1.
|
||||
|
||||
type QueuedEvent = Record<string, unknown> & { kind: "event" | "error" | "vital" };
|
||||
|
||||
const FLUSH_INTERVAL_MS = 10_000;
|
||||
const FLUSH_AT = 25;
|
||||
const MAX_PER_PAYLOAD = 50;
|
||||
const ERROR_DEDUPE_MS = 30_000;
|
||||
const MAX_ERRORS_PER_SESSION = 20;
|
||||
|
||||
let initialized = false;
|
||||
let queue: QueuedEvent[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastRouteId: string | undefined;
|
||||
let errorCount = 0;
|
||||
const recentErrors = new Map<string, number>();
|
||||
|
||||
const isEnabled = (): boolean =>
|
||||
typeof window !== "undefined" &&
|
||||
(import.meta.env.PROD || import.meta.env.VITE_TELEMETRY_DEV === "1");
|
||||
|
||||
const getSessionId = (): string => {
|
||||
try {
|
||||
let id = sessionStorage.getItem("flxn-tsid");
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
sessionStorage.setItem("flxn-tsid", id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return "anon-session";
|
||||
}
|
||||
};
|
||||
|
||||
const location = (): { path: string; routeId?: string } => ({
|
||||
path: window.location.pathname,
|
||||
routeId: lastRouteId,
|
||||
});
|
||||
|
||||
const send = (events: QueuedEvent[], useBeacon: boolean) => {
|
||||
const body = JSON.stringify({ sessionId: getSessionId(), events });
|
||||
|
||||
if (useBeacon && navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/telemetry/beacon", new Blob([body], { type: "application/json" }));
|
||||
return;
|
||||
}
|
||||
|
||||
void fetch("/api/telemetry/beacon", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const flush = (useBeacon = false) => {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
send(queue.slice(0, MAX_PER_PAYLOAD), useBeacon);
|
||||
queue = queue.slice(MAX_PER_PAYLOAD);
|
||||
}
|
||||
};
|
||||
|
||||
const enqueue = (event: QueuedEvent) => {
|
||||
if (!isEnabled()) return;
|
||||
queue.push(event);
|
||||
if (queue.length >= FLUSH_AT) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => flush(), FLUSH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
export const track = (name: string, props?: Record<string, unknown>, value?: number) => {
|
||||
enqueue({ kind: "event", name, props, value, ...location() });
|
||||
};
|
||||
|
||||
const captureError = (
|
||||
source: "window" | "unhandledrejection" | "error-boundary" | "sw",
|
||||
error: unknown
|
||||
) => {
|
||||
if (!isEnabled()) return;
|
||||
if (errorCount >= MAX_ERRORS_PER_SESSION) return;
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: (() => {
|
||||
try {
|
||||
return JSON.stringify(error)?.slice(0, 500) || "Unknown error";
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
})();
|
||||
const stack = error instanceof Error ? error.stack?.slice(0, 8000) : undefined;
|
||||
|
||||
const now = Date.now();
|
||||
const dedupeKey = `${source}|${message}`;
|
||||
const lastSeen = recentErrors.get(dedupeKey);
|
||||
if (lastSeen && now - lastSeen < ERROR_DEDUPE_MS) return;
|
||||
recentErrors.set(dedupeKey, now);
|
||||
|
||||
errorCount++;
|
||||
enqueue({
|
||||
kind: "error",
|
||||
message: message.slice(0, 2000) || "Unknown error",
|
||||
stack,
|
||||
source,
|
||||
...location(),
|
||||
});
|
||||
flush();
|
||||
};
|
||||
|
||||
export const captureBoundaryError = (error: unknown) => {
|
||||
captureError("error-boundary", error);
|
||||
};
|
||||
|
||||
export const initTelemetry = (router: AnyRouter) => {
|
||||
if (initialized || !isEnabled()) return;
|
||||
initialized = true;
|
||||
|
||||
router.subscribe("onResolved", (event) => {
|
||||
const matches = router.state.matches;
|
||||
lastRouteId = matches[matches.length - 1]?.routeId;
|
||||
if (event.pathChanged) {
|
||||
track("page_view");
|
||||
}
|
||||
});
|
||||
|
||||
const initialMatches = router.state.matches;
|
||||
lastRouteId = initialMatches[initialMatches.length - 1]?.routeId;
|
||||
track("page_view");
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
captureError("window", event.error ?? event.message);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
captureError("unhandledrejection", event.reason);
|
||||
});
|
||||
|
||||
// Vitals mostly report at hidden-visibility; their callbacks are registered
|
||||
// before the flush listeners so those reports make the final beacon.
|
||||
void import("web-vitals")
|
||||
.then(({ onLCP, onCLS, onINP, onFCP, onTTFB }) => {
|
||||
const report = (metric: { name: string; value: number; rating: string }) => {
|
||||
enqueue({
|
||||
kind: "vital",
|
||||
metric: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
...location(),
|
||||
});
|
||||
};
|
||||
onLCP(report);
|
||||
onCLS(report);
|
||||
onINP(report);
|
||||
onFCP(report);
|
||||
onTTFB(report);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
track("pwa_installed");
|
||||
flush();
|
||||
});
|
||||
window.addEventListener("beforeinstallprompt", () => {
|
||||
track("pwa_prompt_available");
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flush(true);
|
||||
});
|
||||
window.addEventListener("pagehide", () => flush(true));
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { errorGroupHash, firstMeaningfulFrame } from "./error-hash";
|
||||
|
||||
describe("errorGroupHash", () => {
|
||||
it("is stable across ids, numbers, and URLs in the message", () => {
|
||||
const a = errorGroupHash({
|
||||
message: "Failed to load record abc123def456ghi from https://s3.yohler.net/x/1",
|
||||
source: "window",
|
||||
});
|
||||
const b = errorGroupHash({
|
||||
message: "Failed to load record zzz999yyy888xxx from https://other.host/y/42",
|
||||
source: "window",
|
||||
});
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("is stable across asset content-hash changes in the stack", () => {
|
||||
const stackA = "TypeError: x is not a function\n at t (https://flxn.app/assets/index-Bq2z1a3f.js:1:5000)";
|
||||
const stackB = "TypeError: x is not a function\n at t (https://flxn.app/assets/index-Cd4e9b21.js:1:6789)";
|
||||
const a = errorGroupHash({ message: "x is not a function", stack: stackA, source: "window" });
|
||||
const b = errorGroupHash({ message: "x is not a function", stack: stackB, source: "window" });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("distinguishes different messages", () => {
|
||||
const a = errorGroupHash({ message: "cannot read foo", source: "window" });
|
||||
const b = errorGroupHash({ message: "network request failed", source: "window" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("distinguishes sources", () => {
|
||||
const a = errorGroupHash({ message: "boom", source: "window" });
|
||||
const b = errorGroupHash({ message: "boom", source: "unhandledrejection" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("produces 8-char hex", () => {
|
||||
expect(errorGroupHash({ message: "boom" })).toMatch(/^[0-9a-f]{8}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstMeaningfulFrame", () => {
|
||||
it("returns empty for missing stacks", () => {
|
||||
expect(firstMeaningfulFrame(undefined)).toBe("");
|
||||
expect(firstMeaningfulFrame("Error: boom")).toBe("");
|
||||
});
|
||||
|
||||
it("strips line/column numbers and query strings", () => {
|
||||
const frame = firstMeaningfulFrame(
|
||||
"Error: boom\n at handler (/assets/app-Ab12Cd34.js:10:20)"
|
||||
);
|
||||
expect(frame).not.toMatch(/:\d+:\d+/);
|
||||
expect(frame).toContain("app.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// Deterministic client-error grouping across deploys: volatile fragments
|
||||
// (ids, URLs, asset content hashes) are normalized away before hashing.
|
||||
|
||||
const normalizeMessage = (message: string): string =>
|
||||
message
|
||||
.toLowerCase()
|
||||
.replace(/https?:\/\/[^\s)'"]+/g, "<url>")
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "<uuid>")
|
||||
.replace(/(['"`])(?:(?!\1).)*\1/g, "<str>")
|
||||
.replace(/\b[a-z0-9]{15}\b/g, "<id>")
|
||||
.replace(/\d+/g, "<n>")
|
||||
.trim();
|
||||
|
||||
const FRAME_NOISE = /^(at\s+)?(async\s+)?(module code|<anonymous>|eval)/i;
|
||||
|
||||
// Vite emits content-hashed asset names (index-Bq2z1a3f.js); strip the hash so
|
||||
// the same frame groups across deploys.
|
||||
const normalizeFrame = (frame: string): string =>
|
||||
frame
|
||||
.trim()
|
||||
.replace(/https?:\/\/[^\s)]+\//g, "/")
|
||||
.replace(/(\/[\w.-]+)-[\w]{8,}\.(m?js|css)/g, "$1.$2")
|
||||
.replace(/:\d+:\d+/g, "")
|
||||
.replace(/\?[^\s)]*/g, "");
|
||||
|
||||
export const firstMeaningfulFrame = (stack?: string): string => {
|
||||
if (!stack) return "";
|
||||
const lines = stack.split("\n").slice(1);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || FRAME_NOISE.test(trimmed)) continue;
|
||||
return normalizeFrame(trimmed);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const fnv1a = (input: string): string => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = (hash * 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, "0");
|
||||
};
|
||||
|
||||
export const errorGroupHash = (input: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
source?: string;
|
||||
}): string =>
|
||||
fnv1a(
|
||||
[normalizeMessage(input.message), firstMeaningfulFrame(input.stack), input.source ?? ""].join(
|
||||
"|"
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redactValue } from "@/lib/redact";
|
||||
|
||||
export const MAX_ARGUMENTS_CHARS = 2048;
|
||||
|
||||
export const redactArguments = (data: unknown): unknown => {
|
||||
if (data === undefined) return undefined;
|
||||
|
||||
const redacted = redactValue(data);
|
||||
const serialized = JSON.stringify(redacted) ?? "";
|
||||
if (serialized.length > MAX_ARGUMENTS_CHARS) {
|
||||
return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) };
|
||||
}
|
||||
return redacted;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// Per-request side-channel between the session layer and the global logging
|
||||
// middleware, which runs outside session middleware and cannot see its context.
|
||||
|
||||
const actorByRequest = new WeakMap<Request, { playerId?: string }>();
|
||||
const deniedRecorded = new WeakSet<Request>();
|
||||
|
||||
export const setRequestActor = (request: Request, actor: { playerId?: string }) => {
|
||||
actorByRequest.set(request, actor);
|
||||
};
|
||||
|
||||
export const getRequestActor = (request: Request): { playerId?: string } | undefined =>
|
||||
actorByRequest.get(request);
|
||||
|
||||
export const markDeniedRecorded = (request: Request) => {
|
||||
deniedRecorded.add(request);
|
||||
};
|
||||
|
||||
export const wasDeniedRecorded = (request: Request): boolean => deniedRecorded.has(request);
|
||||
Reference in New Issue
Block a user