import { createMiddleware } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; import { isRedirect } from "@tanstack/react-router"; import { Logger } from "@/lib/logger"; import { redactArguments } from "@/lib/telemetry/redact-args"; import { getRequestActor, markDeniedRecorded, wasDeniedRecorded, } from "@/lib/telemetry/request-context.server"; import type { ActivityInput } from "@/lib/pocketbase/services/activities"; const logger = new Logger("Activities"); export const recordActivity = (activity: ActivityInput) => { void (async () => { try { const { isExcludedPlayerId } = await import("@/lib/telemetry/exclusions.server"); if (await isExcludedPlayerId(activity.player)) return; const { pbAdmin } = await import("@/lib/pocketbase/client"); await pbAdmin.authPromise; await pbAdmin.createActivity(activity); } catch (activityError) { logger.error("Failed to record activity", activityError); } })(); }; const auditErrorMessage = ( error?: { code?: string; userMessage?: string } ): string => { if (!error) return "error"; return [error.code, error.userMessage].filter(Boolean).join(": ") || "error"; }; type Actor = { player_id?: string }; type ServerFnMeta = { name?: string }; // The URL segment is a hash of the fn id; serverFnMeta carries the readable name. const serverFnName = (request: Request, meta?: ServerFnMeta): string => { if (meta?.name) return meta.name; const pathParts = new URL(request.url).pathname.split("/").filter(Boolean); return pathParts[pathParts.length - 1] || "unknown"; }; export const READ_FN_NAME = /^(get|list|search|fetch|preview|is[A-Z])/; const readSampleRate = (): number => { const raw = Number(process.env.TELEMETRY_READ_SAMPLE); return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 1) : 0; }; const shouldRecordSuccess = (name: string): boolean => { if (!READ_FN_NAME.test(name)) return true; const sample = readSampleRate(); return sample > 0 && Math.random() < sample; }; const isControlFlowError = (error: unknown): boolean => error instanceof Response || isRedirect(error); export const recordDeniedServerFn = ( request: Request, actor?: Actor, meta?: ServerFnMeta ) => { markDeniedRecorded(request); recordActivity({ name: serverFnName(request, meta), player: actor?.player_id, duration: 0, success: false, error: "FORBIDDEN: Access denied", user_agent: request.headers.get("user-agent") || undefined, }); }; export const serverFnLoggingMiddleware = createMiddleware({ type: "function", }).server(async ({ next, data, context, serverFnMeta }) => { const request = getRequest(); const name = serverFnName(request, serverFnMeta); const userAgent = request.headers.get("user-agent") || undefined; const safeArgs = redactArguments(data); // Runs outside session middleware; the actor is stashed on the Request and read after next(). const resolveActor = (): string | undefined => getRequestActor(request)?.playerId ?? ((context as any)?.metadata?.player_id as string | undefined); const startTime = Date.now(); try { const result = await next(); const duration = Date.now() - startTime; const envelope = (result as { result?: unknown })?.result; const failed = !!envelope && typeof envelope === "object" && "success" in envelope && (envelope as { success: boolean }).success === false; if (failed || shouldRecordSuccess(name)) { recordActivity({ name, player: resolveActor(), duration, success: !failed, error: failed ? auditErrorMessage( (envelope as { error?: { code?: string; userMessage?: string } }) .error ) : undefined, arguments: safeArgs, user_agent: userAgent, }); } return result; } catch (error) { if (isControlFlowError(error) || wasDeniedRecorded(request)) { throw error; } const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); // Anonymous visitors (crawlers included) hitting authed reads are noise, // not failures worth a row each. if (errorMessage === "Unauthenticated" && !resolveActor()) { throw error; } recordActivity({ name, player: resolveActor(), duration, success: false, error: errorMessage, arguments: safeArgs, user_agent: userAgent, }); throw error; } });