Logging: add missing audit logs (setup, contacts, task create/note, renewal settings); add /api/metrics Prometheus endpoint
This commit is contained in:
parent
daf0a1a83e
commit
77f23e12cd
7 changed files with 222 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
169
ondeck/src/app/api/metrics/route.ts
Normal file
169
ondeck/src/app/api/metrics/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue