Ctrl K
ComunidadGuías…

scripts/check-umami-storage.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

/**
 * Read-only capacity and growth check for the Railway Postgres volume backing
 * the external Umami service.
 *
 * Railway exposes the current volume size through `railway volume list`. The
 * scheduled workflow supplies that JSON and carries a bounded sample history so
 * this check can alert on projected days-to-full as well as absolute usage.
 * It never connects to Postgres and never deletes data.
 */

import {
  existsSync,
  mkdirSync,
  readFileSync,
  renameSync,
  writeFileSync,
} from 'node:fs';
import { dirname } from 'node:path';
import { parseArgs as parseNodeArgs } from 'node:util';

import { isMainModule } from './lib/main-module.mjs';

const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;

export const UMAMI_STORAGE_POLICY = Object.freeze({
  serviceName: 'Postgres Umami',
  // Railway refreshes currentSizeMB only every ~6 hours, so the samples form a
  // staircase. Growth is a least-squares fit over this window (about 12
  // refreshes) rather than a slope against one old sample: on 2026-09-13 a
  // single +1,078 MB refresh against a 24-hour baseline projected 8 days of
  // headroom while the multi-day trend gave 17.
  trendWindowDays: 3,
  minimumTrendSpanDays: 2,
  warningUsageRatio: 0.8,
  criticalUsageRatio: 0.9,
  warningHeadroomDays: 30,
  criticalHeadroomDays: 14,
  // One Railway size refresh. When the volume read keeps timing out, the job
  // stays green until the stored samples are this old, then fails: a Railway
  // latency spike warns, an outage that blinds the monitor alarms.
  maxSampleAgeHours: 6,
});

function finiteNonNegative(value) {
  const number = typeof value === 'number' ? value : Number(value);
  return Number.isFinite(number) && number >= 0 ? number : null;
}

function timestampMs(value) {
  const valueMs = typeof value === 'number'
    ? value
    : value instanceof Date
      ? value.getTime()
      : Date.parse(String(value));
  return Number.isFinite(valueMs) ? valueMs : null;
}

function volumeIdentity(volume) {
  const identity = volume?.id ?? volume?.volumeId ?? volume?.name ?? volume?.serviceName;
  return identity === null || identity === undefined ? null : String(identity);
}

function unwrapCollection(value) {
  if (Array.isArray(value)) return value;
  if (!value || typeof value !== 'object') return [];
  if (Array.isArray(value.edges)) return value.edges.map((edge) => edge?.node ?? edge);
  if (Array.isArray(value.nodes)) return value.nodes;
  return [];
}

export function normalizeVolumeRows(payload) {
  if (Array.isArray(payload)) return payload;
  if (!payload || typeof payload !== 'object') return [];
  return unwrapCollection(payload.volumes ?? payload);
}

function normalizeSamples(samples, nowMs) {
  if (!Array.isArray(samples)) return [];
  const cutoff = nowMs - UMAMI_STORAGE_POLICY.trendWindowDays * DAY_MS;
  return samples
    .map((sample) => {
      const sampledAtMs = timestampMs(sample?.sampledAt);
      const currentSizeMB = finiteNonNegative(sample?.currentSizeMB);
      if (sampledAtMs === null || currentSizeMB === null) return null;
      if (sampledAtMs < cutoff || sampledAtMs > nowMs) return null;
      return {
        sampledAt: new Date(sampledAtMs).toISOString(),
        currentSizeMB,
      };
    })
    .filter(Boolean)
    .sort((a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt));
}

export function updateStorageState(previousState, volume, now = Date.now()) {
  const nowMs = timestampMs(now);
  if (nowMs === null) throw new Error('Storage sample time must be a valid timestamp');
  const capacityMB = finiteNonNegative(volume?.sizeMB);
  if (capacityMB === null || capacityMB <= 0) throw new Error('Umami volume sizeMB must be greater than zero');
  const currentSizeMB = finiteNonNegative(volume?.currentSizeMB);
  if (currentSizeMB === null) throw new Error('Umami volume currentSizeMB must be a non-negative number');
  const identity = volumeIdentity(volume);
  if (identity === null) throw new Error('Umami volume must have a stable identity');

  const priorCapacityMB = finiteNonNegative(previousState?.capacityMB);
  const sameVolume = previousState?.volumeIdentity === identity && priorCapacityMB === capacityMB;
  const samples = (sameVolume ? normalizeSamples(previousState?.samples, nowMs) : [])
    .filter((sample) => Date.parse(sample.sampledAt) !== nowMs);
  samples.push({ sampledAt: new Date(nowMs).toISOString(), currentSizeMB });
  return { version: 1, volumeIdentity: identity, capacityMB, samples };
}

export function evaluateUmamiStorage({ volume, samples = [], now = Date.now() }) {
  const nowMs = timestampMs(now);
  const capacityMB = finiteNonNegative(volume?.sizeMB);
  const currentSizeMB = finiteNonNegative(volume?.currentSizeMB);
  if (nowMs === null) throw new Error('Storage evaluation time must be a valid timestamp');
  if (capacityMB === null || capacityMB <= 0) throw new Error('Umami volume sizeMB must be greater than zero');
  if (currentSizeMB === null) throw new Error('Umami volume currentSizeMB must be a non-negative number');
  if (volume.status !== 'Ready') throw new Error(`Umami volume is not ready: ${volume.status ?? 'unknown'}`);

  const usageRatio = currentSizeMB / capacityMB;
  const points = normalizeSamples(samples, nowMs)
    .filter((sample) => Date.parse(sample.sampledAt) !== nowMs)
    .map((sample) => ({ day: (Date.parse(sample.sampledAt) - nowMs) / DAY_MS, sizeMB: sample.currentSizeMB }));
  points.push({ day: 0, sizeMB: currentSizeMB });

  let growthMBPerDay = null;
  let projectedHeadroomDays = null;
  if (-points[0].day >= UMAMI_STORAGE_POLICY.minimumTrendSpanDays) {
    const meanDay = points.reduce((sum, point) => sum + point.day, 0) / points.length;
    const meanSizeMB = points.reduce((sum, point) => sum + point.sizeMB, 0) / points.length;
    let covariance = 0;
    let variance = 0;
    for (const point of points) {
      covariance += (point.day - meanDay) * (point.sizeMB - meanSizeMB);
      variance += (point.day - meanDay) ** 2;
    }
    const slopeMBPerDay = covariance / variance;
    if (slopeMBPerDay > 0) {
      growthMBPerDay = slopeMBPerDay;
      const remainingMB = Math.max(0, capacityMB - currentSizeMB);
      projectedHeadroomDays = remainingMB / growthMBPerDay;
    } else {
      growthMBPerDay = 0;
      projectedHeadroomDays = Infinity;
    }
  }

  const critical = usageRatio >= UMAMI_STORAGE_POLICY.criticalUsageRatio
    || (projectedHeadroomDays !== null && projectedHeadroomDays <= UMAMI_STORAGE_POLICY.criticalHeadroomDays);
  const warning = critical || usageRatio >= UMAMI_STORAGE_POLICY.warningUsageRatio
    || (projectedHeadroomDays !== null && projectedHeadroomDays <= UMAMI_STORAGE_POLICY.warningHeadroomDays);

  return {
    serviceName: volume.serviceName ?? null,
    volumeName: volume.name ?? null,
    capacityMB,
    currentSizeMB,
    usagePercent: usageRatio * 100,
    growthMBPerDay,
    projectedHeadroomDays,
    status: critical ? 'critical' : warning ? 'warning' : 'healthy',
    alerting: warning,
  };
}

export function evaluateSampleFreshness({ state, now = Date.now() }) {
  const nowMs = timestampMs(now);
  if (nowMs === null) throw new Error('Freshness evaluation time must be a valid timestamp');
  const nextState = { version: 1, samples: [], ...state };
  // Not normalizeSamples: its trend-window cutoff would drop a days-old last
  // sample and turn a long-blind monitor back into "no history".
  let lastSample = null;
  let lastSampleMs = null;
  for (const sample of Array.isArray(state?.samples) ? state.samples : []) {
    const sampledAtMs = timestampMs(sample?.sampledAt);
    if (sampledAtMs === null || sampledAtMs > nowMs || finiteNonNegative(sample?.currentSizeMB) === null) continue;
    if (lastSampleMs === null || sampledAtMs > lastSampleMs) {
      lastSample = sample;
      lastSampleMs = sampledAtMs;
    }
  }

  let lastStatus = null;
  let sinceMs = lastSampleMs;
  if (lastSample) {
    // Re-judge the last measured size as of when it was taken, so a sample
    // that failed its own run as critical keeps failing while Railway is
    // unreadable instead of reading as merely recent.
    const capacityMB = finiteNonNegative(state.capacityMB);
    if (capacityMB !== null && capacityMB > 0) {
      lastStatus = evaluateUmamiStorage({
        volume: { sizeMB: capacityMB, currentSizeMB: finiteNonNegative(lastSample.currentSizeMB), status: 'Ready' },
        samples: state.samples,
        now: lastSampleMs,
      }).status;
    }
  } else {
    // No sample to age: start the clock at the first failed read and keep it
    // in the persisted state, so a lost history plus an outage still fails.
    const unreadSinceMs = timestampMs(state?.unreadSince);
    sinceMs = unreadSinceMs !== null && unreadSinceMs <= nowMs ? unreadSinceMs : nowMs;
    nextState.unreadSince = new Date(sinceMs).toISOString();
  }

  const ageHours = (nowMs - sinceMs) / HOUR_MS;
  return {
    status: ageHours > UMAMI_STORAGE_POLICY.maxSampleAgeHours ? 'stale' : 'fresh',
    ageHours,
    lastStatus,
    state: nextState,
  };
}

function readJson(path) {
  return JSON.parse(readFileSync(path, 'utf8'));
}

function findUmamiVolume(rows, serviceName = UMAMI_STORAGE_POLICY.serviceName) {
  const matches = rows.filter((row) => row?.serviceName === serviceName);
  if (matches.length !== 1) {
    throw new Error(`Expected exactly one Railway volume for ${serviceName}, found ${matches.length}`);
  }
  return matches[0];
}

function writeState(path, state) {
  mkdirSync(dirname(path), { recursive: true });
  const temporaryPath = `${path}.tmp-${process.pid}`;
  writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
  renameSync(temporaryPath, path);
}

function formatDays(value) {
  return value === null ? 'unavailable' : Number.isFinite(value) ? `${value.toFixed(1)} days` : 'no growth';
}

export function parseArguments(argv) {
  const { values } = parseNodeArgs({
    args: argv,
    options: {
      input: { type: 'string' },
      state: { type: 'string' },
      'freshness-only': { type: 'boolean' },
    },
    allowPositionals: false,
    strict: true,
  });
  return values;
}

export function runUmamiStorageCheck({ payload, state = { version: 1, samples: [] }, now = Date.now() } = {}) {
  const rows = normalizeVolumeRows(payload);
  const volume = findUmamiVolume(rows, process.env.UMAMI_POSTGRES_SERVICE_NAME || UMAMI_STORAGE_POLICY.serviceName);
  const nextState = updateStorageState(state, volume, now);
  const result = evaluateUmamiStorage({ volume, samples: nextState.samples, now });
  return { result, state: nextState };
}

async function main() {
  const args = parseArguments(process.argv.slice(2));
  const inputPath = args.input || process.env.UMAMI_STORAGE_INPUT;
  const statePath = args.state || process.env.UMAMI_STORAGE_STATE || '.cache/umami-storage-state.json';
  if (args['freshness-only']) {
    const freshness = evaluateSampleFreshness({ state: existsSync(statePath) ? readJson(statePath) : undefined });
    writeState(statePath, freshness.state);
    const age = freshness.ageHours.toFixed(1);
    if (freshness.status === 'stale') {
      console.error(
        `::error::Railway volume reads keep failing: no Umami capacity sample for ${age} hours `
          + `(limit ${UMAMI_STORAGE_POLICY.maxSampleAgeHours}).`,
      );
      process.exitCode = 1;
    } else if (freshness.lastStatus === 'critical') {
      console.error(`::error::Railway is unreachable, and the last measured Umami storage (${age} hours ago) was critical.`);
      process.exitCode = 1;
    } else {
      const since = freshness.state.unreadSince ? 'reads started failing' : 'the last sample';
      const last = freshness.lastStatus ? `; that sample was ${freshness.lastStatus}` : '';
      console.error(`::warning::Umami capacity was not re-measured; ${since} ${age} hours ago${last}.`);
    }
    return;
  }
  if (!inputPath) throw new Error('Provide Railway volume JSON with --input <path> or UMAMI_STORAGE_INPUT');

  const payload = readJson(inputPath);
  const previousState = existsSync(statePath) ? readJson(statePath) : { version: 1, samples: [] };
  const { result, state } = runUmamiStorageCheck({ payload, state: previousState });
  writeState(statePath, state);

  const growth = result.growthMBPerDay === null
    ? 'growth baseline unavailable'
    : `${(result.growthMBPerDay / 1024).toFixed(3)} GiB/day`;
  console.log(
    `Umami storage ${result.status}: ${result.currentSizeMB.toFixed(1)}/${result.capacityMB.toFixed(1)} MB `
      + `(${result.usagePercent.toFixed(1)}% used), ${growth}, `
      + `projected headroom ${formatDays(result.projectedHeadroomDays)}.`,
  );
  if (result.status === 'critical') {
    console.error('::error::Umami Postgres storage is at a critical capacity or projected-headroom threshold.');
    process.exitCode = 1;
  } else if (result.status === 'warning') {
    console.error('::warning::Umami Postgres storage needs retention or capacity action before the next threshold.');
  }
}

const isMain = isMainModule(import.meta.url, process.argv[1]);
if (isMain) {
  main().catch((error) => {
    console.error(`Umami storage monitor failed: ${error instanceof Error ? error.message : String(error)}`);
    process.exitCode = 1;
  });
}