- NestJS
- Architecture
- Ops
- Sécurité
Un point de terminaison de métriques par produit, sans aucune donnée personnelle, une collecte horaire et des alertes : comment je supervise mes cinq SaaS depuis un cockpit local.
J'exploite cinq produits web en production : ZeroStake, Get Choppy, Ecrivia, Accueil IA et Atelier du Tuning. Chacun a sa base et son back-office.
J'ai donc écrit KCockpit, un cockpit privé qui supervise les cinq applications, affiche leurs indicateurs et pilote leur publication sur les réseaux sociaux. Il a une particularité qui surprend : il ne tourne pas sur un serveur, il tourne sur mon Mac. Cet article décrit la partie supervision : le contrat d'API que chaque produit expose, la collecte horaire, et ce que le choix du local implique. L'hébergement des produits eux-mêmes est un autre sujet, traité dans Cinq SaaS sur un seul VPS.
Un contrat minimal : GET /internal/metrics
Plutôt que de donner au cockpit un accès aux cinq bases de données, chaque produit expose un seul point de terminaison en lecture : GET /internal/metrics, protégé par un jeton bearer propre à l'application. La réponse a toujours la même forme :
{ app, generatedAt, metrics: { users_total, users_24h, users_7d, ...métier } }La règle qui structure tout le reste : aucune donnée personnelle. Uniquement des compteurs agrégés. Pas un email, pas un nom, pas un identifiant. Chaque produit ajoute ses indicateurs métier aux trois compteurs d'inscrits : paris fictifs placés et groupes actifs pour ZeroStake, recherches actives, alertes envoyées sur 24 h et abonnés payants pour Get Choppy, annonces actives et analyses IA pour Atelier du Tuning.
Côté Get Choppy, l'implémentation tient en six comptages lancés en parallèle, avec un cache de 60 secondes :
const [usersTotal, users24h, users7d, searchesActive, alertsSent24h, payingSubscribers] =
await Promise.all([
this.prisma.user.count(),
this.prisma.user.count({ where: { createdAt: { gte: dayAgo } } }),
this.prisma.user.count({ where: { createdAt: { gte: weekAgo } } }),
this.prisma.search.count({ where: { enabled: true } }),
// « Alerte envoyée » = annonce effectivement notifiée (notifiedAt posé).
this.prisma.listing.count({ where: { notifiedAt: { gte: dayAgo } } }),
this.prisma.subscription.count({
where: { status: { in: [...ACTIVE_SUBSCRIPTION_STATUSES] } },
}),
]);Si une métrique par client devient un jour nécessaire, elle passera par le back-office authentifié du produit, pas par ici. Un point de terminaison de supervision qui renvoie des données nominatives finira par les faire fuir.
Le jeton est vérifié en temps constant, et le garde est fail-closed : sans jeton configuré côté serveur, personne ne passe.
const expected = this.config.get<string>('METRICS_TOKEN');
const header = context.switchToHttp().getRequest<Request>().headers.authorization;
const provided = header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
if (!expected || !provided || !this.constantTimeEquals(provided, expected)) {
throw new UnauthorizedException();
}Les deux valeurs passent par SHA-256 avant timingSafeEqual. Cette fonction exige deux tampons de même longueur ; hacher d'abord évite que la longueur du secret se déduise du temps de réponse.
Détail : le chemin réel n'est pas le même partout (préfixe d'API ici, sous-domaine là). Le registre garde l'URL exacte de chaque application, avec la mention « ne pas normaliser ».
Collecter toutes les heures, stocker des chiffres bruts
Le cockpit est une API NestJS avec Prisma et un PostgreSQL local. Toutes les heures, un collecteur interroge les cinq points de terminaison les uns après les autres et enregistre chaque valeur dans une table MetricSnapshot { appSlug, metric, value, capturedAt }.
const response = await fetch(app.metricsUrl, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), // 10 s
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} sur ${app.metricsUrl}`);
}
const payload = (await response.json()) as InternalMetricsResponse;
const entries = Object.entries(payload.metrics ?? {}).filter(
([, value]) => typeof value === 'number' && Number.isFinite(value),
);Le délai borné évite qu'un produit lent bloque les autres. Seuls les nombres finis sont stockés. Et une erreur ne remonte jamais : elle devient un résultat ok: false avec son message, affiché sur la carte de l'application. Un site en panne est une donnée, pas une exception.
La vue d'ensemble reconstruit à partir de ces relevés une série de 30 jours par indicateur (la dernière capture du jour fait foi) et des variations sur 24 heures et sur 7 jours. Quand l'historique ne couvre pas encore la fenêtre, la variation vaut null et s'affiche « — ». Le commentaire du code résume la règle : on n'invente pas de zéro.
Pourquoi le cockpit tourne en local
Le cockpit détient les jetons de supervision des cinq produits et le jeton de publication Meta : il n'existe aucune surface exposée sur Internet pour les attaquer. Les secrets vivent dans un .env local, jamais commité. Et la sonde de santé, qui interroge chaque page d'accueil et chaque point de terminaison avec un délai de 5 secondes et un cache de 60 secondes, ne prend ses URL que dans le registre versionné, jamais dans une saisie : pas de surface SSRF.
Le prix à payer est simple : un Mac éteint ne lance pas de tâches planifiées. Le planificateur rattrape donc tout au démarrage, en arrière-plan, sans jamais bloquer le boot :
async onApplicationBootstrap(): Promise<void> {
this.bootCatchUpAt = new Date().toISOString();
void this.collectMetrics().catch((error) =>
logger.warn({ error: String(error) }, 'Rattrapage collecte au boot en échec'),
);
// ...
// Le Mac dormait peut-être à 09:00 (constaté le 2026-09-17 : API démarrée à
// 11:54, aucun brouillon du jour).
if (new Date().getHours() >= GENERATION_HOUR) {
void this.generateDailyDraft();
}
}Ce rattrapage a ses limites. Les heures pendant lesquelles le Mac dormait ne se reconstituent pas : le point de terminaison renvoie l'état présent, pas l'historique. Les compteurs glissants (users_24h, users_7d) compensent en partie. Le dernier résultat de collecte par application est gardé en mémoire : après un redémarrage, la carte affiche « Aucune collecte depuis le démarrage » jusqu'au premier passage. L'API est lancée au login par launchd.
Des alertes tirées des seuls chiffres collectés
Un tableau de bord qu'il faut penser à ouvrir ne sert qu'à moitié. Un moteur d'alertes passe toutes les 5 minutes, et chacun de ses types repose sur une donnée déjà collectée, jamais sur une supposition. metrics_stale se déclenche quand une application n'a plus été collectée avec succès depuis plus de 3 heures ; une application jamais collectée ne déclenche rien. new_users compare les deux derniers relevés de users_total :
export function detectNewUsers(snapshots: UsersSnapshot[]): NewUsersDetection | null {
if (snapshots.length < 2) return null;
const latest = snapshots[snapshots.length - 1];
const previous = snapshots[snapshots.length - 2];
if (!latest || !previous) return null;
const delta = latest.value - previous.value;
// Un compteur qui stagne ou recule n'est pas une bonne nouvelle : on se tait.
if (delta <= 0) return null;
return { from: previous.value, to: latest.value, delta, /* ... */ };
}Un seul relevé ne prouve rien, une baisse non plus (compte supprimé, environnement remis à zéro) : dans les deux cas, silence. Chaque alerte porte une clé de déduplication et chaque type une fenêtre de silence : 30 minutes pour une panne de site, 1 heure pour les nouveaux inscrits, 3 heures pour une collecte en retard. Sans cela, une panne d'une heure produirait douze notifications identiques.
Elles partent en notification macOS et, en option, sur Telegram.
Le registre compte aujourd'hui onze applications, mais seules les cinq qui exposent ce point de terminaison sont supervisées. Les six autres sont déclarées supervised: false : sans cela, chacune déclencherait une fausse panne permanente.
Ce que j'en retiens
- Un contrat de supervision doit être pauvre. Des compteurs agrégés derrière un jeton, sans aucune donnée personnelle : le cockpit n'a pas besoin d'en savoir plus, donc il n'en reçoit pas plus.
- Fail-closed, toujours. Un jeton absent côté serveur doit fermer la porte, pas l'ouvrir.
- Local, c'est moins de surface et plus de rattrapage. Le choix tient, à condition d'écrire le rattrapage au démarrage et d'accepter les heures perdues.
- N'afficher que ce qui a été mesuré. Une variation inconnue s'affiche « — », pas zéro.
Vous avez plusieurs produits en production et aucune vue d'ensemble fiable ? Écrivez-moi, on regarde ce qu'il faut réellement mesurer.