- wau/mau trailing scans run hourly and on day finalization, not every tick - exclusion lookup failures cached 30s so an st outage cannot amplify - unverified relation filter dropped; player emptiness checked in js - anonymous unauthenticated read failures no longer logged - alerts and exclusions ensure supertokens init on cold pods
151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
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;
|
|
}
|
|
});
|