169 lines
8.3 KiB
TypeScript
169 lines
8.3 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { prisma } from '@/lib/db'
|
|
|
|
/**
|
|
* GET /api/metrics
|
|
* Prometheus text-format metrics endpoint.
|
|
* Protected by METRICS_SECRET header (Bearer token) or CRON_SECRET as fallback.
|
|
* Scrape config example:
|
|
* - job_name: horizon
|
|
* bearer_token: <METRICS_SECRET>
|
|
* static_configs:
|
|
* - targets: ['horizon.seubert.cloud']
|
|
* metrics_path: /api/metrics
|
|
* scheme: https
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
const authHeader = request.headers.get('authorization') || ''
|
|
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader
|
|
const secret = process.env.METRICS_SECRET || process.env.CRON_SECRET
|
|
|
|
if (!secret || token !== secret) {
|
|
return new NextResponse('Unauthorized', { status: 401 })
|
|
}
|
|
|
|
try {
|
|
const now = new Date()
|
|
const todayStart = new Date(now); todayStart.setHours(0, 0, 0, 0)
|
|
const weekAgo = new Date(now.getTime() - 7 * 86400000)
|
|
const monthAgo = new Date(now.getTime() - 30 * 86400000)
|
|
|
|
const [
|
|
totalClients,
|
|
totalPolicies,
|
|
activePolicies,
|
|
totalGroups,
|
|
totalUsers,
|
|
activeUsers,
|
|
totalTasks,
|
|
tasksByStatus,
|
|
tasksByDept,
|
|
overdueTasks,
|
|
tasksCreatedToday,
|
|
tasksCompletedToday,
|
|
tasksCreatedWeek,
|
|
tasksCompletedWeek,
|
|
auditLogsToday,
|
|
auditLogsWeek,
|
|
syncRuns,
|
|
lastSyncStatus,
|
|
importRuns,
|
|
lastImportStatus,
|
|
setupQueueCount,
|
|
] = await Promise.all([
|
|
prisma.client.count(),
|
|
prisma.policy.count(),
|
|
prisma.policy.count({ where: { status: 'Active' } }),
|
|
prisma.policyGroup.count(),
|
|
prisma.user.count(),
|
|
prisma.user.count({ where: { isActive: true } }),
|
|
prisma.task.count(),
|
|
prisma.task.groupBy({ by: ['status'], _count: { id: true } }),
|
|
prisma.task.groupBy({ by: ['department'], _count: { id: true } }),
|
|
prisma.task.count({ where: { dueDate: { lt: now }, status: { notIn: ['COMPLETED', 'NA', 'CANCELLED'] } } }),
|
|
prisma.task.count({ where: { createdAt: { gte: todayStart } } }),
|
|
prisma.task.count({ where: { completedAt: { gte: todayStart } } }),
|
|
prisma.task.count({ where: { createdAt: { gte: weekAgo } } }),
|
|
prisma.task.count({ where: { completedAt: { gte: weekAgo } } }),
|
|
prisma.auditLog.count({ where: { createdAt: { gte: todayStart } } }),
|
|
prisma.auditLog.count({ where: { createdAt: { gte: weekAgo } } }),
|
|
prisma.syncLog.count(),
|
|
prisma.syncLog.findFirst({ orderBy: { startedAt: 'desc' }, select: { status: true, startedAt: true } }),
|
|
prisma.shapeImportRun.count(),
|
|
prisma.shapeImportRun.findFirst({ orderBy: { startedAt: 'desc' }, select: { status: true, startedAt: true } }),
|
|
prisma.client.count({
|
|
where: {
|
|
designation: { name: { in: ['Shape', 'Shape 2'] } },
|
|
OR: [{ claimsAdvocateId: null }, { setupCompletedAt: null }],
|
|
},
|
|
}),
|
|
])
|
|
|
|
const lines: string[] = []
|
|
|
|
const gauge = (name: string, help: string, value: number, labels?: Record<string, string>) => {
|
|
const labelStr = labels
|
|
? '{' + Object.entries(labels).map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`).join(',') + '}'
|
|
: ''
|
|
lines.push(`# HELP ${name} ${help}`)
|
|
lines.push(`# TYPE ${name} gauge`)
|
|
lines.push(`${name}${labelStr} ${value}`)
|
|
}
|
|
|
|
const counter = (name: string, help: string, value: number, labels?: Record<string, string>) => {
|
|
const labelStr = labels
|
|
? '{' + Object.entries(labels).map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`).join(',') + '}'
|
|
: ''
|
|
lines.push(`# HELP ${name} ${help}`)
|
|
lines.push(`# TYPE ${name} counter`)
|
|
lines.push(`${name}${labelStr} ${value}`)
|
|
}
|
|
|
|
// ── Clients ────────────────────────────────────────────────────────────
|
|
gauge('horizon_clients_total', 'Total number of clients', totalClients)
|
|
gauge('horizon_clients_setup_queue', 'Clients pending setup (no advocate or group setup)', setupQueueCount)
|
|
|
|
// ── Policies ───────────────────────────────────────────────────────────
|
|
gauge('horizon_policies_total', 'Total number of policies', totalPolicies)
|
|
gauge('horizon_policies_active', 'Active policies', activePolicies)
|
|
gauge('horizon_policy_groups_total', 'Total policy groups', totalGroups)
|
|
|
|
// ── Users ──────────────────────────────────────────────────────────────
|
|
gauge('horizon_users_total', 'Total users', totalUsers)
|
|
gauge('horizon_users_active', 'Active users', activeUsers)
|
|
|
|
// ── Tasks ──────────────────────────────────────────────────────────────
|
|
gauge('horizon_tasks_total', 'Total tasks', totalTasks)
|
|
gauge('horizon_tasks_overdue', 'Overdue tasks (past due date, not terminal)', overdueTasks)
|
|
|
|
lines.push('# HELP horizon_tasks_by_status Tasks grouped by status')
|
|
lines.push('# TYPE horizon_tasks_by_status gauge')
|
|
for (const row of tasksByStatus) {
|
|
lines.push(`horizon_tasks_by_status{status="${row.status}"} ${row._count.id}`)
|
|
}
|
|
|
|
lines.push('# HELP horizon_tasks_by_department Tasks grouped by department')
|
|
lines.push('# TYPE horizon_tasks_by_department gauge')
|
|
for (const row of tasksByDept) {
|
|
lines.push(`horizon_tasks_by_department{department="${row.department}"} ${row._count.id}`)
|
|
}
|
|
|
|
counter('horizon_tasks_created_today', 'Tasks created today', tasksCreatedToday)
|
|
counter('horizon_tasks_completed_today', 'Tasks completed today', tasksCompletedToday)
|
|
counter('horizon_tasks_created_7d', 'Tasks created in last 7 days', tasksCreatedWeek)
|
|
counter('horizon_tasks_completed_7d', 'Tasks completed in last 7 days', tasksCompletedWeek)
|
|
|
|
// ── Audit ──────────────────────────────────────────────────────────────
|
|
counter('horizon_audit_events_today', 'Audit log entries today', auditLogsToday)
|
|
counter('horizon_audit_events_7d', 'Audit log entries in last 7 days', auditLogsWeek)
|
|
|
|
// ── Sync ───────────────────────────────────────────────────────────────
|
|
gauge('horizon_sync_runs_total', 'Total AFW sync runs', syncRuns)
|
|
if (lastSyncStatus) {
|
|
gauge('horizon_sync_last_success', 'Last sync was successful (1=yes, 0=no)',
|
|
lastSyncStatus.status === 'completed' ? 1 : 0)
|
|
gauge('horizon_sync_last_run_age_seconds', 'Seconds since last sync run',
|
|
Math.floor((now.getTime() - new Date(lastSyncStatus.startedAt).getTime()) / 1000))
|
|
}
|
|
|
|
// ── Shape Import ───────────────────────────────────────────────────────
|
|
gauge('horizon_shape_import_runs_total', 'Total Shape import runs', importRuns)
|
|
if (lastImportStatus) {
|
|
gauge('horizon_shape_import_last_success', 'Last Shape import was successful (1=yes, 0=no)',
|
|
lastImportStatus.status === 'completed' ? 1 : 0)
|
|
gauge('horizon_shape_import_last_run_age_seconds', 'Seconds since last Shape import',
|
|
Math.floor((now.getTime() - new Date(lastImportStatus.startedAt).getTime()) / 1000))
|
|
}
|
|
|
|
// ── Scrape meta ────────────────────────────────────────────────────────
|
|
gauge('horizon_scrape_timestamp_seconds', 'Unix timestamp of this scrape', Math.floor(now.getTime() / 1000))
|
|
|
|
return new NextResponse(lines.join('\n') + '\n', {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' },
|
|
})
|
|
} catch (error: any) {
|
|
console.error('Metrics error:', error)
|
|
return new NextResponse('Internal server error', { status: 500 })
|
|
}
|
|
}
|