/**
 * API request instrumentation that powers the Dashboard "Delivery API"
 * tiles (request volume / error rate / avg latency / p95).
 *
 * ## Why per-minute aggregated buckets and not per-request rows
 *
 * A single 24-hour window at 100 req/s on 20 routes is ~17M raw rows,
 * but the same window aggregated to (minute × route × status-bucket)
 * is ≤ 1440 × 20 × 5 = 144k rows — and in practice far less because
 * most route/minute combinations have zero traffic. The reader
 * (`/stats/api-delivery`) folds the last 1440 minute buckets into
 * four scalars; the writer does one atomic UPSERT per request via
 * raw SQL.
 *
 * ## Why p95 via histogram and not from raw samples
 *
 * Storing a histogram per bucket gives us percentile estimates with
 * ~10% error vs ground truth while keeping the table tiny. The
 * `LATENCY_BUCKETS` boundary list is log-scale (powers of 2 in
 * milliseconds) so it covers ~1 ms heartbeats through multi-second
 * outliers in 12 columns. p95 falls out as a `findIndex` once the
 * histogram is merged across all minute buckets in the window.
 *
 * ## Why fire-and-forget and not awaited
 *
 * Metric recording MUST NOT slow the user-facing response. The
 * `record()` call dispatches the UPSERT to a `void`-discarded promise
 * so a slow DB round-trip never blocks the request that produced
 * the metric. Errors are swallowed at the source — losing a metric
 * sample is strictly preferable to surfacing a metric-pipeline error
 * to the user.
 *
 * ## Why we self-exempt the dashboard polling routes
 *
 * The dashboard refetches `/stats` and `/stats/api-delivery` on a
 * timer. If we counted those requests they'd dominate the volume
 * tile and inflate the error rate (a logged-out tab would hit
 * `/stats` and get 401 repeatedly). The route-normaliser excludes
 * them explicitly — see `shouldRecord`.
 */
import { createLogger } from './logger.js';
import { getDB } from '../db.js';
const logger = createLogger('api-metrics');
// ─── Public constants ──────────────────────────────────────────────────────
/**
 * Upper bound (exclusive) of each histogram bucket, in milliseconds.
 * The last bucket is `>= 1024 ms` (no upper bound).
 *
 * Indexed:
 *   0: <1ms     1: 1-2ms     2: 2-4ms     3: 4-8ms
 *   4: 8-16ms   5: 16-32ms   6: 32-64ms   7: 64-128ms
 *   8: 128-256  9: 256-512  10: 512-1024 11: >=1024
 */
export const LATENCY_BUCKETS = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
export const HISTOGRAM_LENGTH = LATENCY_BUCKETS.length + 1; // 12
// ─── Pure helpers ──────────────────────────────────────────────────────────
/**
 * Bucket a status code into one of 5 families. We keep the 1xx family
 * out of the public type because no CMS route ever responds with one;
 * if it ever happens, the `other` bucket catches it instead of
 * silently being lost.
 */
export function statusBucketOf(status) {
    if (status >= 200 && status < 300)
        return '2xx';
    if (status >= 300 && status < 400)
        return '3xx';
    if (status >= 400 && status < 500)
        return '4xx';
    if (status >= 500 && status < 600)
        return '5xx';
    return 'other';
}
/**
 * Return the index (0..HISTOGRAM_LENGTH-1) of the histogram bucket
 * that this latency falls into. NaN / negative values fall in bucket
 * 0 (treated as "<1ms") so a metric write never crashes on a bad
 * input.
 */
export function histogramBucketIndex(durationMs) {
    if (!Number.isFinite(durationMs) || durationMs < 1)
        return 0;
    for (let i = 0; i < LATENCY_BUCKETS.length; i++) {
        if (durationMs < LATENCY_BUCKETS[i])
            return i;
    }
    return LATENCY_BUCKETS.length; // overflow bucket
}
/**
 * Build a fresh histogram array with a single sample placed in the
 * correct bucket. Used to produce the JSON payload for an INSERT.
 */
export function makeHistogramFor(durationMs) {
    const arr = new Array(HISTOGRAM_LENGTH).fill(0);
    arr[histogramBucketIndex(durationMs)] = 1;
    return arr;
}
/**
 * Element-wise add two histograms. Tolerant of either operand being
 * missing or shorter than `HISTOGRAM_LENGTH` — defends against schema
 * mismatches (e.g. an old row written before a bucket boundary change).
 */
export function mergeHistograms(a, b) {
    const out = new Array(HISTOGRAM_LENGTH).fill(0);
    for (let i = 0; i < HISTOGRAM_LENGTH; i++) {
        out[i] = (a?.[i] ?? 0) + (b[i] ?? 0);
    }
    return out;
}
/**
 * Snap a Date to the start of its UTC minute. Returns a new Date —
 * never mutates the input.
 */
export function bucketStartFor(now = new Date()) {
    const ms = typeof now === 'number' ? now : now.getTime();
    return new Date(ms - (ms % 60_000));
}
/**
 * Estimate percentile-N from a merged histogram. Returns the upper
 * bound of the bucket that contains the requested percentile —
 * which is the right "user-visible" answer (we are happy to say
 * "p95 < 256ms" rather than guess the exact value inside the
 * 128-256 bucket).
 *
 * For the overflow bucket (no upper bound), we report
 * `LATENCY_BUCKETS[last]` as a lower-bound estimate; callers
 * displaying this should annotate with a "≥" prefix if they want
 * to expose the open-ended nature.
 */
export function estimatePercentile(histogram, percentile) {
    const total = histogram.reduce((s, v) => s + v, 0);
    if (total === 0)
        return 0;
    const target = total * percentile;
    let acc = 0;
    for (let i = 0; i < HISTOGRAM_LENGTH; i++) {
        acc += histogram[i] ?? 0;
        if (acc >= target) {
            if (i < LATENCY_BUCKETS.length)
                return LATENCY_BUCKETS[i];
            return LATENCY_BUCKETS[LATENCY_BUCKETS.length - 1] * 2; // overflow estimate
        }
    }
    return 0;
}
/**
 * Normalise a request path to bound metric-row cardinality.
 *
 *   /collections/posts/abc123        →  /collections/posts/:id
 *   /globals/site-settings           →  /globals/:slug
 *   /collections/posts/abc123/restore→  /collections/posts/:id/restore
 *   /unknown/long/path/that/leaks    →  /other
 *
 * The "/other" bucket catches anything we don't explicitly know
 * about so a single hostile client with random URLs can't blow up
 * the table.
 */
export function normalizeRoute(pathname) {
    // Strip query string + trailing slash (except root).
    let p = pathname.split('?')[0] ?? '/';
    if (p.length > 1 && p.endsWith('/'))
        p = p.slice(0, -1);
    // Strip the `/api/cms` prefix if still present — depending on where
    // we instrument, the path may or may not have been rewritten yet.
    if (p.startsWith('/api/cms'))
        p = p.slice('/api/cms'.length) || '/';
    // Static or top-level routes — pass through verbatim.
    if (KNOWN_STATIC_ROUTES.has(p))
        return p;
    // Pattern matchers for cardinality-bounding.
    for (const { pattern, replacement } of ROUTE_PATTERNS) {
        if (pattern.test(p))
            return p.replace(pattern, replacement);
    }
    return '/other';
}
/**
 * Routes that the dashboard polls itself, plus webhooks the cron
 * fires. Counting these would either (a) cause feedback loops
 * inflating the volume tile or (b) report cron-only traffic the
 * editor never produces. The instrumentation skips them.
 */
export function shouldRecord(normalizedRoute) {
    return !SKIP_ROUTES.has(normalizedRoute);
}
// ─── Lookup tables (kept module-scope so they're built once) ───────────────
/**
 * Verbatim routes — the dashboard, auth, health, etc. Keep this
 * small; if your CMS exposes many top-level routes, prefer
 * `ROUTE_PATTERNS` so the table stays bounded.
 */
const KNOWN_STATIC_ROUTES = new Set([
    '/',
    '/health',
    '/stats',
    '/stats/api-delivery',
    '/stats/content-health',
    '/auth/login',
    '/auth/logout',
    '/auth/me',
    '/auth/csrf',
    '/auth/forgot-password',
    '/auth/reset-password',
    '/auth/totp/login',
    '/setup/status',
    '/setup/create-admin',
    '/search',
    '/users',
    '/api-keys',
    '/notifications',
    '/notifications/stream',
    '/notifications/read-all',
    '/media',
    '/media/upload',
    '/forms',
    '/seo/config',
    '/seo/analyze',
    '/redirects',
    '/script-tags',
    '/saved-sections',
    '/page-templates',
    '/webhooks',
    '/webhooks/test',
]);
/**
 * Skip these from the metric — they're either the dashboard polling
 * itself (feedback loop) or cron-only traffic that distorts the
 * editor-facing tiles.
 */
const SKIP_ROUTES = new Set([
    '/stats',
    '/stats/api-delivery',
    '/stats/content-health',
    '/health',
    '/notifications/stream', // long-lived SSE; counting once on open is misleading
]);
const ROUTE_PATTERNS = [
    {
        pattern: /^\/collections\/[^/]+\/[^/]+\/restore$/,
        replacement: '/collections/:slug/:id/restore',
    },
    {
        pattern: /^\/collections\/[^/]+\/[^/]+\/versions(?:\/[^/]+)?$/,
        replacement: '/collections/:slug/:id/versions',
    },
    {
        pattern: /^\/collections\/[^/]+\/[^/]+\/duplicate$/,
        replacement: '/collections/:slug/:id/duplicate',
    },
    { pattern: /^\/collections\/[^/]+\/[^/]+$/, replacement: '/collections/:slug/:id' },
    { pattern: /^\/collections\/[^/]+$/, replacement: '/collections/:slug' },
    { pattern: /^\/globals\/[^/]+$/, replacement: '/globals/:slug' },
    { pattern: /^\/media\/[^/]+$/, replacement: '/media/:id' },
    { pattern: /^\/forms\/[^/]+\/submit$/, replacement: '/forms/:id/submit' },
    { pattern: /^\/forms\/[^/]+\/submissions(?:\/[^/]+)?$/, replacement: '/forms/:id/submissions' },
    { pattern: /^\/forms\/[^/]+$/, replacement: '/forms/:id' },
    { pattern: /^\/users\/[^/]+$/, replacement: '/users/:id' },
    { pattern: /^\/api-keys\/[^/]+$/, replacement: '/api-keys/:id' },
    { pattern: /^\/redirects\/[^/]+$/, replacement: '/redirects/:id' },
    { pattern: /^\/script-tags\/[^/]+$/, replacement: '/script-tags/:id' },
    { pattern: /^\/saved-sections\/[^/]+$/, replacement: '/saved-sections/:id' },
    { pattern: /^\/page-templates\/[^/]+$/, replacement: '/page-templates/:id' },
    { pattern: /^\/webhooks\/[^/]+(?:\/.+)?$/, replacement: '/webhooks/:id' },
    { pattern: /^\/notifications\/[^/]+\/read$/, replacement: '/notifications/:id/read' },
    { pattern: /^\/cron\/.+/, replacement: '/cron/:job' },
    { pattern: /^\/dev\/.+/, replacement: '/dev/:fn' },
    { pattern: /^\/seo\/.+/, replacement: '/seo/:fn' },
    { pattern: /^\/share\/.+/, replacement: '/share/:token' },
    { pattern: /^\/preview\/.+/, replacement: '/preview/:fn' },
    { pattern: /^\/page-builder\/.+/, replacement: '/page-builder/:fn' },
    { pattern: /^\/ai\/.+/, replacement: '/ai/:fn' },
    { pattern: /^\/auth\/.+/, replacement: '/auth/:fn' },
    { pattern: /^\/resolve(?:\/.+)?$/, replacement: '/resolve' },
];
// ─── Writer ────────────────────────────────────────────────────────────────
/**
 * Record one API request to the metrics table. **Fire-and-forget** —
 * callers MUST NOT `await` this. Errors are swallowed; the next
 * request retries, and 1-minute aggregates are robust to occasional
 * write loss.
 *
 * Writes a single SQL UPSERT so two concurrent requests landing in
 * the same bucket interleave cleanly via Postgres's MVCC + the
 * unique-on-conflict path. The histogram column is merged
 * element-wise inside SQL, so we never read-modify-write from JS.
 */
export function recordApiRequest(sample) {
    const route = normalizeRoute(sample.pathname);
    if (!shouldRecord(route))
        return;
    // Schedule the write but do not await. Returning `void` is the
    // contract — callers shouldn't be tempted to `.then()` on it.
    void writeMetricBucket({
        bucketStart: bucketStartFor(),
        route,
        statusBucket: statusBucketOf(sample.status),
        durationMs: sample.durationMs,
    });
}
async function writeMetricBucket(args) {
    let db;
    try {
        db = getDB();
    }
    catch {
        // DB not initialised yet (e.g. dev server hot-reload race) —
        // skip silently. There's nothing useful to log here and we
        // don't want a stack trace on every dropped sample.
        return;
    }
    // Some hosts ship a Prisma client that doesn't include the
    // `apiRequestMetric` model (e.g. third-party deploys that copied
    // an older schema). Guard so we degrade silently rather than 500ing
    // every request.
    if (!db || typeof db.$executeRawUnsafe !== 'function' || !('apiRequestMetric' in db)) {
        return;
    }
    const histogram = makeHistogramFor(args.durationMs);
    try {
        // Atomic INSERT...ON CONFLICT DO UPDATE. The histogram merge is
        // expressed as a `jsonb_build_array` over the existing + new
        // bucket counts so we never read the prior row from JS.
        //
        // CUID-ish ID generation via gen_random_uuid() keeps the ID
        // schema-compatible with the model's `@default(cuid())` (both
        // are 26+ char ASCII strings; Prisma's cuid is preferred but
        // SQL-level UUIDs are accepted by the column type and not
        // surfaced to consumers).
        await db.$executeRawUnsafe(`
        INSERT INTO "actuate_api_request_metrics"
          ("id", "bucketStart", "route", "statusBucket", "count", "sumLatencyMs", "maxLatencyMs", "latencyHistogram", "updatedAt")
        VALUES (
          'm_' || replace(gen_random_uuid()::text, '-', ''),
          $1::timestamp,
          $2,
          $3,
          1,
          $4,
          $4,
          $5::jsonb,
          NOW()
        )
        ON CONFLICT ("bucketStart", "route", "statusBucket") DO UPDATE
          SET "count"        = "actuate_api_request_metrics"."count" + 1,
              "sumLatencyMs" = "actuate_api_request_metrics"."sumLatencyMs" + EXCLUDED."sumLatencyMs",
              "maxLatencyMs" = GREATEST("actuate_api_request_metrics"."maxLatencyMs", EXCLUDED."maxLatencyMs"),
              "latencyHistogram" = (
                SELECT jsonb_agg(
                  COALESCE(("actuate_api_request_metrics"."latencyHistogram"->>i)::int, 0) +
                  COALESCE((EXCLUDED."latencyHistogram"->>i)::int, 0)
                  ORDER BY i
                )
                FROM generate_series(0, ${HISTOGRAM_LENGTH - 1}) AS i
              ),
              "updatedAt"    = NOW()
      `, args.bucketStart, args.route, args.statusBucket, args.durationMs, JSON.stringify(histogram));
    }
    catch (err) {
        // Throttle the log so a misconfigured DB doesn't drown the
        // logger. We rate-limit *log lines*, not metric writes — every
        // request still attempts a write, but only one error/minute
        // surfaces in console.
        rateLimitedWarn('recordApiRequest write failed', err instanceof Error ? err.message : String(err));
    }
}
let lastWarnAt = 0;
function rateLimitedWarn(msg, detail) {
    const now = Date.now();
    if (now - lastWarnAt < 60_000)
        return;
    lastWarnAt = now;
    logger.warn(msg, { detail });
}
//# sourceMappingURL=api-metrics.js.map