/** * Aggregate counters for the requests that reached token verification. * * The rate limiter keys on the client IP, so that is the one thing these must * not keep: no address, no token, no query, and no timestamp per request. What * is left says whether the instance is being abused, whether a client is * broken, and how the shared request budget is spent across the endpoints that * share it, none of which needs to know who asked. */ import { getRejectedTokenCacheHits } from "./rejectedTokens.ts"; import { RATE_LIMIT_DURATION_SECONDS, RATE_LIMIT_POINTS, type RejectionReason, THUMBNAIL_RATE_LIMIT_DURATION_SECONDS, THUMBNAIL_RATE_LIMIT_POINTS, } from "./verifyTokenAndRateLimit.ts"; /** * Which endpoint family a request was aimed at. `other` is what keeps the * totals closed when a new endpoint starts using the same verification funnel * and nobody extends this list. */ export type AuthorizationSurface = | "search" | "pageContent" | "thumbnail" | "inference" | "other"; interface SurfaceCounts { authorized: number; rejected: number; } let authorized = 0; const reasons: Record = { rateLimited: 0, missingToken: 0, invalidToken: 0, }; const bySurface: Record = { search: { authorized: 0, rejected: 0 }, pageContent: { authorized: 0, rejected: 0 }, thumbnail: { authorized: 0, rejected: 0 }, inference: { authorized: 0, rejected: 0 }, other: { authorized: 0, rejected: 0 }, }; export function recordAuthorizedRequest(surface: AuthorizationSurface): void { authorized++; bySurface[surface].authorized++; } export function recordRejectedRequest( surface: AuthorizationSurface, reason: RejectionReason, ): void { reasons[reason]++; bySurface[surface].rejected++; } /** * `authorized` plus every entry of `reasons` sums to `requests`, and each half * of `bySurface` sums to its side of that, so a request that goes uncounted * shows up as a gap rather than being lost silently. * * The limiter's own settings ride along because the counts cannot be read * without them: 40 rate-limited requests means something different at 10 * points per 10 seconds than it would at 100. */ export function getAuthorizationStats() { const rejected = Object.values(reasons).reduce( (total, count) => total + count, 0, ); const requests = authorized + rejected; return { requests, authorized, rejectedRate: Number(((rejected / requests) * 100 || 0).toFixed(1)), rejectedTokenCacheHits: getRejectedTokenCacheHits(), reasons: { ...reasons }, // Deep copy, so a caller holding a snapshot for comparison does not watch // it change under them as later requests arrive. bySurface: Object.fromEntries( Object.entries(bySurface).map(([surface, counts]) => [ surface, { ...counts }, ]), ) as Record, limiter: { points: RATE_LIMIT_POINTS, durationSeconds: RATE_LIMIT_DURATION_SECONDS, thumbnail: { points: THUMBNAIL_RATE_LIMIT_POINTS, durationSeconds: THUMBNAIL_RATE_LIMIT_DURATION_SECONDS, }, }, }; }