import { prisma } from '../prisma.js'; import { AppStatusValue } from '$lib/utils/constants.js'; /** * Tiny Prometheus-text metrics gatherer. Avoids the prom-client dependency * (~150KB + extra runtime memory) by emitting the exposition format directly. * If we later want histograms or counters with labels at high cardinality, * swap this out for prom-client. */ interface CounterSnapshot { readonly name: string; readonly help: string; readonly value: number; readonly labels?: Record; } function escapeLabel(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); } function renderLabels(labels?: Record): string { if (!labels) return ''; const parts = Object.entries(labels).map(([k, v]) => `${k}="${escapeLabel(v)}"`); return parts.length ? `{${parts.join(',')}}` : ''; } /** * In-memory counter / gauge state. Process-local — Prometheus is expected to * scrape a single launcher instance (the app is SQLite-bound to one process * anyway). Reset on restart, like most lightweight setups. */ class MetricRegistry { private counters = new Map(); private gauges = new Map(); incCounter(name: string, by = 1): void { this.counters.set(name, (this.counters.get(name) ?? 0) + by); } setGauge(name: string, value: number): void { this.gauges.set(name, value); } getCounter(name: string): number { return this.counters.get(name) ?? 0; } snapshot(): { counters: Map; gauges: Map } { return { counters: new Map(this.counters), gauges: new Map(this.gauges) }; } } export const metricRegistry = new MetricRegistry(); // Counter names — keep them ASCII identifiers (Prometheus naming rules). export const Counters = { HEALTHCHECK_TOTAL: 'wal_healthcheck_total', HEALTHCHECK_FAILED: 'wal_healthcheck_failed_total', LOGIN_SUCCESS: 'wal_login_success_total', LOGIN_FAILED: 'wal_login_failed_total', NOTIFICATION_SENT: 'wal_notification_sent_total', NOTIFICATION_FAILED: 'wal_notification_failed_total', INTEGRATION_FETCH_TOTAL: 'wal_integration_fetch_total', INTEGRATION_FETCH_FAILED: 'wal_integration_fetch_failed_total' } as const; /** * Build the full exposition. Combines: * - process-local counters (login attempts, healthcheck ticks, etc.) * - DB-backed gauges (current online/offline app count, user count, etc.) */ export async function renderMetrics(): Promise { const lines: string[] = []; // --- Static help/type lines + counter snapshots --- const COUNTER_HELP: Record = { [Counters.HEALTHCHECK_TOTAL]: 'Total healthcheck ticks executed since process start', [Counters.HEALTHCHECK_FAILED]: 'Healthcheck ticks where any app returned offline', [Counters.LOGIN_SUCCESS]: 'Successful local logins since process start', [Counters.LOGIN_FAILED]: 'Failed local logins since process start', [Counters.NOTIFICATION_SENT]: 'Notification dispatch attempts', [Counters.NOTIFICATION_FAILED]: 'Notification dispatch failures', [Counters.INTEGRATION_FETCH_TOTAL]: 'Integration fetch attempts', [Counters.INTEGRATION_FETCH_FAILED]: 'Integration fetch failures' }; const { counters } = metricRegistry.snapshot(); for (const name of Object.values(Counters)) { const value = counters.get(name) ?? 0; lines.push(`# HELP ${name} ${COUNTER_HELP[name]}`); lines.push(`# TYPE ${name} counter`); lines.push(`${name} ${value}`); } // --- DB-backed gauges --- const gauges: CounterSnapshot[] = []; try { const [totalApps, healthchecked, totalUsers, totalBoards] = await Promise.all([ prisma.app.count(), prisma.app.count({ where: { healthcheckEnabled: true } }), prisma.user.count(), prisma.board.count() ]); gauges.push( { name: 'wal_apps_total', help: 'Total apps registered', value: totalApps }, { name: 'wal_apps_healthchecked_total', help: 'Apps with healthcheck enabled', value: healthchecked }, { name: 'wal_users_total', help: 'Total user accounts', value: totalUsers }, { name: 'wal_boards_total', help: 'Total boards', value: totalBoards } ); // Latest status per app — broken down by status value. // Subquery: for each app, take the most recent AppStatus row. const latest = await prisma.$queryRaw<{ status: string; count: number }[]>` SELECT status, COUNT(*) AS count FROM ( SELECT appId, status, ROW_NUMBER() OVER (PARTITION BY appId ORDER BY checkedAt DESC) AS rn FROM AppStatus ) WHERE rn = 1 GROUP BY status `; for (const status of Object.values(AppStatusValue)) { const row = latest.find((r) => r.status === status); gauges.push({ name: 'wal_app_status', help: 'Current count of apps by latest status', value: Number(row?.count ?? 0), labels: { status } }); } } catch (err) { // DB issue — emit an "up" gauge of 0 so scrapers can alert on it. // eslint-disable-next-line no-console console.warn('[metrics] failed to gather DB gauges:', err); lines.push(`# HELP wal_db_up 1 if the metrics endpoint could read from the DB`); lines.push(`# TYPE wal_db_up gauge`); lines.push(`wal_db_up 0`); lines.push(''); return lines.join('\n'); } // Group same-name gauges so we emit HELP/TYPE once. const grouped = new Map(); for (const g of gauges) { const arr = grouped.get(g.name); if (arr) arr.push(g); else grouped.set(g.name, [g]); } for (const [name, samples] of grouped) { lines.push(`# HELP ${name} ${samples[0].help}`); lines.push(`# TYPE ${name} gauge`); for (const s of samples) { lines.push(`${name}${renderLabels(s.labels)} ${s.value}`); } } lines.push(`# HELP wal_db_up 1 if the metrics endpoint could read from the DB`); lines.push(`# TYPE wal_db_up gauge`); lines.push(`wal_db_up 1`); lines.push(''); return lines.join('\n'); }