Métriques business dans /api/metrics : - Abonnements par tier/status (BASIC/PRO/ENTERPRISE × ACTIVE/CANCELED) - Nouveaux abonnements ce mois vs mois dernier - Désabonnements / churn ce mois vs mois dernier - Utilisateurs actifs 7j / 30j (proxy : note modifiée) - Nouvelles inscriptions 7j / ce mois - Runs agents IA par status (30j + aujourd'hui) + tokens consommés - Usage IA par feature (requêtes + tokens ce mois) - Logins aujourd'hui / ce mois (via AuditLog) - Sessions brainstorm ce mois - Flashcards total + reviews ce mois Alertes Prometheus : - HighChurnRate (> 10 désabonnements ce mois) - NoNewUsersLast7Days (aucune inscription 7j) - AgentRunsHighErrorRate (> 20% erreurs agents) - BusinessMetricsCollectionFailed Hardening monitoring : - Ports monitoring → 127.0.0.1 (plus exposés publiquement) - Images pinned (prometheus v2.53.0, grafana 11.1.0, etc.) - alertmanager-bridge fake → metalmatze/alertmanager-bot:0.4.3 - /api/metrics sécurisé avec METRICS_TOKEN bearer - Prometheus auth bearer via credentials_file - Redis AOF + 256mb, healthcheck → /api/build-info - repeat_interval 4h, inhibit_rules alertmanager - Secrets CI/CD : AUTH_GOOGLE_SECRET, METRICS_TOKEN, GRAFANA, MCP_API_KEY Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { redis } from '@/lib/redis'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
export async function GET(req: Request) {
|
|
// Secure endpoint with bearer token (METRICS_TOKEN env var)
|
|
const metricsToken = process.env.METRICS_TOKEN
|
|
if (metricsToken) {
|
|
const authHeader = req.headers.get('authorization') ?? ''
|
|
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''
|
|
if (token !== metricsToken) {
|
|
return new NextResponse('Unauthorized', { status: 401 })
|
|
}
|
|
}
|
|
|
|
const lines: string[] = []
|
|
|
|
const metric = (name: string, help: string, type: string, value: number | string, labels = '') => {
|
|
lines.push(`# HELP ${name} ${help}`)
|
|
lines.push(`# TYPE ${name} ${type}`)
|
|
lines.push(labels ? `${name}{${labels}} ${value}` : `${name} ${value}`)
|
|
}
|
|
|
|
// Multiple labeled values for the same metric name
|
|
const metricLabeled = (name: string, help: string, type: string, rows: Array<{ labels: string; value: number }>) => {
|
|
lines.push(`# HELP ${name} ${help}`)
|
|
lines.push(`# TYPE ${name} ${type}`)
|
|
for (const row of rows) {
|
|
lines.push(`${name}{${row.labels}} ${row.value}`)
|
|
}
|
|
}
|
|
|
|
// ── Uptime ──────────────────────────────────────────────────────────────
|
|
metric('memento_uptime_seconds', 'Application uptime in seconds', 'gauge', process.uptime().toFixed(2))
|
|
|
|
// ── Infrastructure ───────────────────────────────────────────────────────
|
|
try {
|
|
const dbStart = Date.now()
|
|
const [noteCount, notebookCount, userCount] = await Promise.all([
|
|
prisma.note.count(),
|
|
prisma.notebook.count(),
|
|
prisma.user.count(),
|
|
])
|
|
const dbLatency = Date.now() - dbStart
|
|
metric('memento_db_up', 'Database connectivity (1=up, 0=down)', 'gauge', 1)
|
|
metric('memento_db_latency_ms', 'Database query latency in milliseconds', 'gauge', dbLatency)
|
|
metric('memento_notes_total', 'Total number of notes', 'gauge', noteCount)
|
|
metric('memento_notebooks_total', 'Total number of notebooks', 'gauge', notebookCount)
|
|
metric('memento_users_total', 'Total number of users', 'gauge', userCount)
|
|
} catch {
|
|
metric('memento_db_up', 'Database connectivity (1=up, 0=down)', 'gauge', 0)
|
|
}
|
|
|
|
try {
|
|
const redisStart = Date.now()
|
|
await redis.ping()
|
|
const dbSize = await redis.dbsize()
|
|
const info = await redis.info('memory')
|
|
const redisLatency = Date.now() - redisStart
|
|
const memMatch = info.match(/used_memory:(\d+)/)
|
|
const usedMemoryBytes = memMatch ? parseInt(memMatch[1]) : 0
|
|
metric('memento_redis_up', 'Redis connectivity (1=up, 0=down)', 'gauge', 1)
|
|
metric('memento_redis_latency_ms', 'Redis ping latency in milliseconds', 'gauge', redisLatency)
|
|
metric('memento_redis_keys_total', 'Total number of Redis keys', 'gauge', dbSize)
|
|
metric('memento_redis_memory_bytes', 'Redis used memory in bytes', 'gauge', usedMemoryBytes)
|
|
} catch {
|
|
metric('memento_redis_up', 'Redis connectivity (1=up, 0=down)', 'gauge', 0)
|
|
}
|
|
|
|
const mem = process.memoryUsage()
|
|
metric('memento_process_heap_used_bytes', 'Node.js heap used in bytes', 'gauge', mem.heapUsed)
|
|
metric('memento_process_heap_total_bytes', 'Node.js heap total in bytes', 'gauge', mem.heapTotal)
|
|
metric('memento_process_rss_bytes', 'Node.js RSS memory in bytes', 'gauge', mem.rss)
|
|
|
|
// ── Business metrics ─────────────────────────────────────────────────────
|
|
try {
|
|
const now = new Date()
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
|
const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59)
|
|
const last7days = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
|
const last30days = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
|
|
|
// ── Subscriptions par tier ──
|
|
const subsByTier = await prisma.subscription.groupBy({
|
|
by: ['tier', 'status'],
|
|
_count: { _all: true },
|
|
})
|
|
const subRows = subsByTier.map(r => ({
|
|
labels: `tier="${r.tier}",status="${r.status}"`,
|
|
value: r._count._all,
|
|
}))
|
|
metricLabeled(
|
|
'memento_subscriptions_total',
|
|
'Total subscriptions by tier and status',
|
|
'gauge',
|
|
subRows,
|
|
)
|
|
|
|
// Totaux agrégés utiles
|
|
const activeSubs = subsByTier
|
|
.filter(r => r.status === 'ACTIVE')
|
|
.reduce((s, r) => s + r._count._all, 0)
|
|
const canceledSubs = subsByTier
|
|
.filter(r => r.status === 'CANCELED')
|
|
.reduce((s, r) => s + r._count._all, 0)
|
|
metric('memento_subscriptions_active_total', 'Total active subscriptions (all tiers)', 'gauge', activeSubs)
|
|
metric('memento_subscriptions_canceled_total', 'Total canceled subscriptions (all tiers)', 'gauge', canceledSubs)
|
|
|
|
// ── Nouveaux abonnements ce mois ──
|
|
const newSubsThisMonth = await prisma.subscription.count({
|
|
where: { createdAt: { gte: startOfMonth }, status: 'ACTIVE' },
|
|
})
|
|
const newSubsLastMonth = await prisma.subscription.count({
|
|
where: { createdAt: { gte: startOfLastMonth, lte: endOfLastMonth }, status: 'ACTIVE' },
|
|
})
|
|
metric('memento_subscriptions_new_this_month', 'New active subscriptions created this month', 'gauge', newSubsThisMonth)
|
|
metric('memento_subscriptions_new_last_month', 'New active subscriptions created last month', 'gauge', newSubsLastMonth)
|
|
|
|
// ── Désabonnements (cancelAtPeriodEnd ou canceledAt ce mois) ──
|
|
const churnsThisMonth = await prisma.subscription.count({
|
|
where: {
|
|
OR: [
|
|
{ canceledAt: { gte: startOfMonth } },
|
|
{ cancelAtPeriodEnd: true, updatedAt: { gte: startOfMonth } },
|
|
],
|
|
},
|
|
})
|
|
const churnsLastMonth = await prisma.subscription.count({
|
|
where: {
|
|
OR: [
|
|
{ canceledAt: { gte: startOfLastMonth, lte: endOfLastMonth } },
|
|
{ cancelAtPeriodEnd: true, updatedAt: { gte: startOfLastMonth, lte: endOfLastMonth } },
|
|
],
|
|
},
|
|
})
|
|
metric('memento_churn_this_month', 'Cancellations / pending cancellations this month', 'gauge', churnsThisMonth)
|
|
metric('memento_churn_last_month', 'Cancellations / pending cancellations last month', 'gauge', churnsLastMonth)
|
|
|
|
// ── Utilisateurs actifs ──
|
|
const activeUsers7d = await prisma.note.groupBy({
|
|
by: ['userId'],
|
|
where: { updatedAt: { gte: last7days } },
|
|
})
|
|
const activeUsers30d = await prisma.note.groupBy({
|
|
by: ['userId'],
|
|
where: { updatedAt: { gte: last30days } },
|
|
})
|
|
metric('memento_active_users_7d', 'Users who modified at least one note in the last 7 days', 'gauge', activeUsers7d.length)
|
|
metric('memento_active_users_30d', 'Users who modified at least one note in the last 30 days', 'gauge', activeUsers30d.length)
|
|
|
|
// Nouveaux utilisateurs
|
|
const newUsers7d = await prisma.user.count({ where: { createdAt: { gte: last7days } } })
|
|
const newUsersThisMonth = await prisma.user.count({ where: { createdAt: { gte: startOfMonth } } })
|
|
metric('memento_new_users_7d', 'New user registrations in the last 7 days', 'gauge', newUsers7d)
|
|
metric('memento_new_users_this_month', 'New user registrations this month', 'gauge', newUsersThisMonth)
|
|
|
|
// ── Agents IA ──
|
|
const agentsByStatus = await prisma.agentAction.groupBy({
|
|
by: ['status'],
|
|
_count: { _all: true },
|
|
where: { createdAt: { gte: last30days } },
|
|
})
|
|
const agentRows = agentsByStatus.map(r => ({
|
|
labels: `status="${r.status}"`,
|
|
value: r._count._all,
|
|
}))
|
|
metricLabeled(
|
|
'memento_agent_runs_30d',
|
|
'Agent runs by status in the last 30 days',
|
|
'gauge',
|
|
agentRows,
|
|
)
|
|
|
|
const agentRunsToday = await prisma.agentAction.count({
|
|
where: { createdAt: { gte: new Date(now.getFullYear(), now.getMonth(), now.getDate()) } },
|
|
})
|
|
metric('memento_agent_runs_today', 'Agent runs triggered today', 'gauge', agentRunsToday)
|
|
|
|
// Tokens consommés par les agents
|
|
const agentTokens = await prisma.agentAction.aggregate({
|
|
_sum: { tokensUsed: true },
|
|
where: { createdAt: { gte: startOfMonth } },
|
|
})
|
|
metric('memento_agent_tokens_this_month', 'Total tokens consumed by agents this month', 'gauge', agentTokens._sum.tokensUsed ?? 0)
|
|
|
|
// ── Usage IA par feature (ce mois) ──
|
|
const usageByFeature = await prisma.usageLog.groupBy({
|
|
by: ['feature'],
|
|
_sum: { requestsCount: true, tokensUsed: true },
|
|
where: { periodStart: { gte: startOfMonth } },
|
|
})
|
|
const usageRequestRows = usageByFeature.map(r => ({
|
|
labels: `feature="${r.feature}"`,
|
|
value: r._sum.requestsCount ?? 0,
|
|
}))
|
|
const usageTokenRows = usageByFeature.map(r => ({
|
|
labels: `feature="${r.feature}"`,
|
|
value: r._sum.tokensUsed ?? 0,
|
|
}))
|
|
metricLabeled('memento_ai_requests_this_month', 'AI API requests by feature this month', 'gauge', usageRequestRows)
|
|
metricLabeled('memento_ai_tokens_this_month', 'AI tokens consumed by feature this month', 'gauge', usageTokenRows)
|
|
|
|
// ── Logins (AuditLog) ──
|
|
const loginsToday = await prisma.auditLog.count({
|
|
where: {
|
|
action: 'LOGIN',
|
|
createdAt: { gte: new Date(now.getFullYear(), now.getMonth(), now.getDate()) },
|
|
},
|
|
})
|
|
const loginsThisMonth = await prisma.auditLog.count({
|
|
where: { action: 'LOGIN', createdAt: { gte: startOfMonth } },
|
|
})
|
|
metric('memento_logins_today', 'Login events today', 'gauge', loginsToday)
|
|
metric('memento_logins_this_month', 'Login events this month', 'gauge', loginsThisMonth)
|
|
|
|
// ── Brainstorm sessions ──
|
|
const brainstormThisMonth = await prisma.brainstormSession.count({
|
|
where: { createdAt: { gte: startOfMonth } },
|
|
})
|
|
metric('memento_brainstorm_sessions_this_month', 'Brainstorm sessions created this month', 'gauge', brainstormThisMonth)
|
|
|
|
// ── Flashcards ──
|
|
const flashcardsTotal = await prisma.flashcard.count()
|
|
const flashcardsReviewedThisMonth = await prisma.flashcardReview.count({
|
|
where: { reviewedAt: { gte: startOfMonth } },
|
|
})
|
|
metric('memento_flashcards_total', 'Total flashcards in the system', 'gauge', flashcardsTotal)
|
|
metric('memento_flashcard_reviews_this_month', 'Flashcard review events this month', 'gauge', flashcardsReviewedThisMonth)
|
|
} catch (err) {
|
|
console.error('[metrics] Business metrics error:', err)
|
|
metric('memento_business_metrics_error', 'Business metrics collection failed (1=error)', 'gauge', 1)
|
|
}
|
|
|
|
const body = lines.join('\n') + '\n'
|
|
|
|
return new NextResponse(body, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
},
|
|
})
|
|
}
|