Logging: add missing audit logs (setup, contacts, task create/note, renewal settings); add /api/metrics Prometheus endpoint

This commit is contained in:
lorentz 2026-04-12 23:40:57 +00:00
parent daf0a1a83e
commit 77f23e12cd
7 changed files with 222 additions and 0 deletions

View file

@ -64,6 +64,15 @@ export async function PUT(request: NextRequest) {
}),
])
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'RENEWAL_SETTINGS_UPDATED',
entityType: 'AppSetting',
newValues: { windowDays, rule },
},
})
return NextResponse.json({ windowDays, rule })
} catch (error) {
console.error('Renewal settings PUT error:', error)

View file

@ -40,5 +40,14 @@ export async function POST(
const contact = await prisma.clientContact.create({
data: { clientId: id, label: label.trim(), name: name.trim(), phone: phone || null, email: email || null, notes: notes || null },
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'CLIENT_CONTACT_CREATED',
entityType: 'ClientContact',
entityId: contact.id,
newValues: { clientId: id, label: contact.label, name: contact.name },
},
})
return NextResponse.json(contact, { status: 201 })
}

View file

@ -126,6 +126,16 @@ async function handleSave(
select: { id: true, name: true, renewalDate: true, setupCompletedAt: true, claimsAdvocateId: true },
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: isDraft ? 'CLIENT_SETUP_DRAFT' : 'CLIENT_SETUP_COMPLETED',
entityType: 'Client',
entityId: clientId,
newValues: { groupCount: body.groups.length, isDraft },
},
})
return NextResponse.json({ client: updated })
} catch (error) {
console.error('Setup save error:', error)

View file

@ -0,0 +1,169 @@
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 })
}
}

View file

@ -74,6 +74,16 @@ export async function POST(
})
}
await prisma.auditLog.create({
data: {
userId,
action: 'TASK_NOTE_ADDED',
entityType: 'Task',
entityId: id,
newValues: { noteId: note.id, statusChange: status ?? null },
},
})
return NextResponse.json(note, { status: 201 })
} catch (error) {
console.error('Task notes POST error:', error)

View file

@ -193,6 +193,16 @@ export async function POST(request: NextRequest) {
},
})
await prisma.auditLog.create({
data: {
userId: (session.user as any).id,
action: 'TASK_CREATED',
entityType: 'Task',
entityId: task.id,
newValues: { title: task.title, clientId, isAdHoc: isAdHoc ?? false },
},
})
return NextResponse.json(task, { status: 201 })
} catch (error) {
console.error('Create task API error:', error)

View file

@ -47,6 +47,11 @@ export default withAuth(
return true
}
// Allow metrics endpoint (secured by Bearer token)
if (path === '/api/metrics') {
return true
}
// Require authentication for all other paths
return !!token
},