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:
2026-08-25 21:33:25 -07:00
parent 15aeb64b19
commit e2863c1f6c
70 changed files with 2545 additions and 275 deletions
+4 -1
View File
@@ -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>;
}
+325
View File
@@ -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
View File
@@ -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(),
+43
View File
@@ -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;
}
});
+125
View File
@@ -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;
},
});
+72
View File
@@ -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);
});
});
+48
View File
@@ -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];
+185
View File
@@ -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));
};
+55
View File
@@ -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");
});
});
+55
View File
@@ -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(
"|"
)
);
+14
View File
@@ -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);