Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 113 additions & 48 deletions apps/api/src/alerting.ts

Large diffs are not rendered by default.

20 changes: 7 additions & 13 deletions apps/api/src/discord-billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,13 @@ export async function syncDiscordPlans(log: Logger): Promise<SyncResult> {
lastSync = { at: new Date().toISOString(), ok: false, entitlements: 0, changed: 0, error: 'ENTITLEMENTS_UNAVAILABLE' };
consecutiveFailures += 1;
if (consecutiveFailures >= FAILURES_BEFORE_INCIDENT) {
await openIncident('discord.sync', {
category: 'billing',
severity: 'warning',
title: 'Sincronizzazione abbonamenti Discord ferma',
description: 'Gli abbonamenti attivi non si possono leggere da Discord: nuovi acquisti, rinnovi e disdette non arrivano ai piani dei server.'
}).catch(() => null);
await openIncident('discord.sync', { category: 'billing', severity: 'warning', message: 'sync.stopped' }).catch(() => null);
}
return lastSync;
}

consecutiveFailures = 0;
await resolveIncident('discord.sync', { title: 'Sincronizzazione abbonamenti ripristinata', description: (duration) => `Gli abbonamenti Discord tornano ad arrivare ai piani dopo ${duration}.` }).catch(() => null);
await resolveIncident('discord.sync', 'sync.restored').catch(() => null);
const now = new Date();
const discordPlans = plansFromEntitlements(entitlements, skus, now);
const rows = await prisma.guildSettings.findMany({
Expand Down Expand Up @@ -86,9 +81,9 @@ export async function syncDiscordPlans(log: Logger): Promise<SyncResult> {
});
await recordAlert({
category: 'billing', severity: 'info', level: 'ok',
title: row.planSource === 'DISCORD' && row.planTier !== plan.tier ? 'Abbonamento Discord cambiato' : 'Nuovo abbonamento Discord',
description: `Il server ora ha il piano ${PLANS[plan.tier].name}.`,
fields: [{ name: 'Server', value: `\`${row.guildId}\`` }]
key: row.planSource === 'DISCORD' && row.planTier !== plan.tier ? 'subscription.changed' : 'subscription.new',
params: { plan: PLANS[plan.tier].name },
fields: [{ key: 'server', value: `\`${row.guildId}\`` }]
}).catch(() => null);
}
} else {
Expand All @@ -100,9 +95,8 @@ export async function syncDiscordPlans(log: Logger): Promise<SyncResult> {
await systemAudit('plan.discord_ended', 'GUILD', row.guildId, { previousTier: row.planTier, entitlementId: row.planEntitlementId, applied });
await recordAlert({
category: 'billing', severity: 'info',
title: 'Abbonamento Discord terminato',
description: 'Il server è tornato al piano Free.',
fields: [{ name: 'Server', value: `\`${row.guildId}\`` }]
key: 'subscription.ended',
fields: [{ key: 'server', value: `\`${row.guildId}\`` }]
}).catch(() => null);
}
changed += 1;
Expand Down
41 changes: 19 additions & 22 deletions apps/api/src/monitoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { prisma } from '@sentinel/db';
import { LOG_LEVEL_RANK, type LogBuffer, type LogLevelName } from '@sentinel/shared';
import { requireSuperAdmin } from './auth.js';
import { botHealth, getBotLogs } from './discord.js';
import { encryptText, unprotectJson } from './security.js';
import { encryptText } from './security.js';
import { superAdminAudit } from './super-audit.js';
import { alertWebhookUrl, openIncident, postAlertWebhook, resolveIncident } from './alerting.js';
import { alertWebhookUrl, openIncident, postAlertWebhook, renderEvent, resolveIncident, statusLocale } from './alerting.js';
import { normalizeAlertLocale, renderAlertMessage, type AlertMessageKey, type AlertParams } from '@sentinel/shared';

/**
* Super console monitoring: the bot watchdog, the instance event feed and the
Expand All @@ -32,9 +33,12 @@ type BotState = { status: 'unknown' | 'up' | 'down'; since: string; lastCheck: s
const state: BotState = { status: 'unknown', since: new Date().toISOString(), lastCheck: null, failures: 0, guilds: null };
export const botState = () => ({ ...state });

async function webhook(level: 'ok' | 'error', title: string, description: string, log: Logger) {
async function webhook(level: 'ok' | 'error', key: AlertMessageKey, params: AlertParams, log: Logger) {
const url = await alertWebhookUrl().catch(() => null);
if (url) await postAlertWebhook(url, level, title, description).catch((error) => log.warn({ err: error }, 'Alarm webhook failed'));
if (!url) return;
const locale = await statusLocale().catch(() => 'en' as const);
const text = renderAlertMessage(key, params, locale);
await postAlertWebhook(url, level, text.title, text.description ?? '', locale).catch((error) => log.warn({ err: error }, 'Alarm webhook failed'));
}

async function checkBot(log: Logger) {
Expand All @@ -49,24 +53,19 @@ async function checkBot(log: Logger) {
state.since = now.toISOString();
}
// Also closes an incident left open by a previous API process.
const downtime = await resolveIncident('bot.down', {
title: 'Bot ripristinato',
description: (duration) => `Sentinel è di nuovo connesso a Discord dopo ${duration} di interruzione (${health.guilds} server).`
}).catch(() => null);
if (downtime) await webhook('ok', 'Bot ripristinato', `Sentinel è di nuovo connesso a Discord dopo ${downtime} di interruzione.`, log);
const durationMs = await resolveIncident('bot.down', 'bot.restored', { guilds: health.guilds }).catch(() => null);
if (durationMs) await webhook('ok', 'bot.restored', { guilds: health.guilds, durationMs }, log);
return;
}
state.failures += 1;
if (state.failures >= FAILURES_BEFORE_DOWN && state.status !== 'down') {
const wasUp = state.status === 'up';
state.status = 'down';
state.since = now.toISOString();
const description = health
? 'Il processo del bot risponde ma non è connesso a Discord: i log non vengono registrati.'
: `Il bot non risponde da circa ${Math.round(FAILURES_BEFORE_DOWN * CHECK_INTERVAL_MS / 60_000)} minuti${wasUp ? '' : ' (dall’avvio dell’API)'}: i log non vengono registrati.`;
const params = { mode: health ? 'disconnected' : 'silent', minutes: Math.round(FAILURES_BEFORE_DOWN * CHECK_INTERVAL_MS / 60_000), sinceStart: wasUp ? null : 1 };
// logOnly: the bot cannot post it; the webhook and the external monitor do.
const opened = await openIncident('bot.down', { category: 'availability', severity: 'critical', title: 'Bot non raggiungibile', description, logOnly: true }).catch(() => false);
if (opened) await webhook('error', 'Bot non raggiungibile', description, log);
const opened = await openIncident('bot.down', { category: 'availability', severity: 'critical', message: 'bot.down', params, logOnly: true }).catch(() => false);
if (opened) await webhook('error', 'bot.down', params, log);
}
}

Expand All @@ -88,18 +87,14 @@ export async function registerMonitoringRoutes(app: FastifyInstance, apiLogs: Lo
app.get('/api/super/monitor', async (request, reply) => {
const session = await requireSuperAdmin(request, reply);
if (!session) return;
const locale = normalizeAlertLocale((request.query as { locale?: string }).locale);
const [rows, url] = await Promise.all([
prisma.instanceEvent.findMany({ orderBy: { createdAt: 'desc' }, take: 200 }),
alertWebhookUrl().catch(() => null)
]);
const events = rows.map((row) => {
let details: { description?: string | null; fields?: Array<{ name: string; value: string }> } = {};
try {
details = unprotectJson(row.details) as typeof details;
} catch {
details = { description: null, fields: [] };
}
return { id: row.id, source: row.source, level: row.level, category: row.category, severity: row.severity, deliveryState: row.deliveryState, title: row.title, description: details.description ?? null, fields: details.fields ?? [], createdAt: row.createdAt };
const text = renderEvent(row, locale);
return { id: row.id, source: row.source, level: row.level, category: row.category, severity: row.severity, deliveryState: row.deliveryState, title: text.title, description: text.description, fields: text.fields, createdAt: row.createdAt };
});
return { bot: botState(), events, webhook: url ? { configured: true, hint: `…${url.slice(-6)}` } : { configured: false, hint: null } };
});
Expand Down Expand Up @@ -140,7 +135,9 @@ export async function registerMonitoringRoutes(app: FastifyInstance, apiLogs: Lo
const url = await alertWebhookUrl().catch(() => null);
if (!url) return reply.code(400).send({ error: 'WEBHOOK_NOT_SET' });
try {
await postAlertWebhook(url, 'info', 'Allarmi attivi', 'Messaggio di prova dalla super console: qui arriveranno l’avviso e i promemoria quando il bot non è raggiungibile, e la notizia del ripristino.');
const locale = await statusLocale().catch(() => 'en' as const);
const text = renderAlertMessage('webhook.test', {}, locale);
await postAlertWebhook(url, 'info', text.title, text.description ?? '', locale);
} catch {
return reply.code(502).send({ error: 'WEBHOOK_FAILED' });
}
Expand Down
26 changes: 11 additions & 15 deletions apps/bot/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,20 +90,16 @@ export function registerHandlers(client: Client, status: StatusNotifier) {
intentionalLeaves.add(guild.id);
setTimeout(() => intentionalLeaves.delete(guild.id), 120_000).unref();
await leaveGuild(guild, reason).catch(() => null);
const why = {
'blocked-guild': 'il server è in blacklist',
'blocked-installer': 'chi ha aggiunto il bot è in blacklist'
}[reason];
status.notify({
level: 'warn',
category: 'security',
severity: 'warning',
title: 'Installazione rifiutata',
description: `Sentinel è uscito da **${guild.name}**: ${why}.`,
key: 'install.rejected',
params: { guild: guild.name, reason },
fields: [
{ name: 'Server', value: `${guild.name}\n\`${guild.id}\`` },
{ name: 'Membri', value: String(guild.memberCount) },
{ name: 'Aggiunto da', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : 'sconosciuto' }
{ key: 'server', value: `${guild.name}\n\`${guild.id}\`` },
{ key: 'members', value: String(guild.memberCount) },
{ key: 'addedBy', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : '—' }
]
});
};
Expand All @@ -128,11 +124,11 @@ export function registerHandlers(client: Client, status: StatusNotifier) {
level: 'ok',
category: 'installations',
severity: 'info',
title: 'Sentinel aggiunto a un server',
key: 'guild.added',
fields: [
{ name: 'Server', value: `${guild.name}\n\`${guild.id}\`` },
{ name: 'Membri', value: String(guild.memberCount) },
{ name: 'Aggiunto da', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : 'sconosciuto' }
{ key: 'server', value: `${guild.name}\n\`${guild.id}\`` },
{ key: 'members', value: String(guild.memberCount) },
{ key: 'addedBy', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : '—' }
]
});
await sendWelcome(guild);
Expand Down Expand Up @@ -750,8 +746,8 @@ export function registerHandlers(client: Client, status: StatusNotifier) {
level: 'info',
category: 'installations',
severity: 'info',
title: 'Sentinel rimosso da un server',
fields: [{ name: 'Server', value: `${guild.name ?? 'sconosciuto'}\n\`${guild.id}\`` }]
key: 'guild.removed',
fields: [{ key: 'server', value: `${guild.name ?? '—'}\n\`${guild.id}\`` }]
});
}
await markGuildRemoved(guild.id);
Expand Down
32 changes: 16 additions & 16 deletions apps/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ installDiscordCapabilityPolicy(client, ({ method, route }) => {
level: 'error',
category: 'security',
severity: 'critical',
title: 'Operazione bloccata dalla politica di sicurezza',
description: `Il bot ha tentato una chiamata non consentita e la politica l’ha bloccata: \`${method} ${route}\`.`,
key: 'policy.blocked',
params: { method, route },
dedupeKey: `policy:${method}:${route}`
});
});
Expand All @@ -62,9 +62,9 @@ onQuotaExceeded((guildId, limit, tier) => {
level: 'warn',
category: 'usage',
severity: 'info',
title: 'Quota giornaliera raggiunta',
description: `**${guild?.name ?? guildId}** ha superato ${limit.toLocaleString('it-IT')} eventi oggi (piano ${tier}): gli eventi successivi non vengono registrati fino a mezzanotte UTC.`,
fields: [{ name: 'Server', value: `\`${guildId}\`` }],
key: 'quota.reached',
params: { guild: guild?.name ?? guildId, limit, plan: tier },
fields: [{ key: 'server', value: `\`${guildId}\`` }],
dedupeKey: `quota:${guildId}`
});
});
Expand Down Expand Up @@ -95,11 +95,11 @@ client.once(Events.ClientReady, async (ready) => {
level: 'ok',
category: 'availability',
severity: 'info',
title: 'Sentinel online',
description: `Connesso a Discord come **${ready.user.tag}**.`,
key: 'bot.online',
params: { user: ready.user.tag },
fields: [
{ name: 'Server', value: String(ready.guilds.cache.size) },
{ name: 'Avvio', value: `<t:${Math.floor(Date.now() / 1000)}:F>` }
{ key: 'servers', value: String(ready.guilds.cache.size) },
{ key: 'startedAt', value: `<t:${Math.floor(Date.now() / 1000)}:F>` }
]
});
await markMissingGuildsRemoved(ready.guilds.cache.keys())
Expand All @@ -120,9 +120,9 @@ async function runMaintenance() {
level: 'info',
category: 'installations',
severity: 'info',
title: 'Dati di server rimossi cancellati',
description: `Cancellati i dati di ${purged.length} server da cui Sentinel è stato rimosso da più di ${REMOVED_GUILD_GRACE_DAYS} giorni.`,
fields: [{ name: 'Server', value: purged.slice(0, 20).map((id) => `\`${id}\``).join('\n') }]
key: 'guilds.purged',
params: { count: purged.length, days: REMOVED_GUILD_GRACE_DAYS },
fields: [{ key: 'servers', value: purged.slice(0, 20).map((id) => `\`${id}\``).join('\n') }]
});
}
} catch (error) {
Expand All @@ -134,16 +134,16 @@ async function runMaintenance() {
// console, not to the servers being logged.
client.on(Events.Warn, (warning) => {
logger.warn({ warning: redactText(warning) }, 'Discord client warning');
status.notify({ level: 'warn', category: 'errors', severity: 'warning', title: 'Warning del client Discord', description: redactText(warning) });
status.notify({ level: 'warn', category: 'errors', severity: 'warning', key: 'client.warning', params: { detail: redactText(warning) } });
});

client.on(Events.Error, (error) => {
logger.error({ error: errorText(error) }, 'Discord client error');
status.notify({ level: 'error', category: 'errors', severity: 'warning', title: 'Errore del client Discord', description: errorText(error) });
status.notify({ level: 'error', category: 'errors', severity: 'warning', key: 'client.error', params: { detail: errorText(error) } });
});

client.on(Events.ShardResume, (shardId, replayed) => {
status.notify({ level: 'info', category: 'availability', severity: 'info', title: 'Connessione a Discord ripristinata', description: `Shard ${shardId}: ${replayed} eventi recuperati.`, dedupeKey: 'shard-resume' });
status.notify({ level: 'info', category: 'availability', severity: 'info', key: 'shard.resumed', params: { shard: shardId, replayed }, dedupeKey: 'shard-resume' });
});

setInterval(() => void runMaintenance(), 6 * 60 * 60 * 1000).unref();
Expand Down Expand Up @@ -171,7 +171,7 @@ const shutdown = async (signal: string) => {

process.on('unhandledRejection', (reason) => {
logger.error({ reason: errorText(reason) }, 'Unhandled promise rejection');
status.notify({ level: 'error', category: 'errors', severity: 'warning', title: 'Errore non gestito nel bot', description: errorText(reason) });
status.notify({ level: 'error', category: 'errors', severity: 'warning', key: 'bot.unhandled', params: { detail: errorText(reason) } });
});

process.on('uncaughtException', (error) => {
Expand Down
3 changes: 1 addition & 2 deletions apps/bot/src/internal-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,7 @@ export function startInternalApi(client: Client, secret: string, port = 3002, st
level: 'info',
category: 'availability',
severity: 'info',
title: 'Notifiche di stato attive',
description: 'Messaggio di prova inviato dalla super console. Qui arriveranno avvio del bot, errori e server aggiunti o rimossi.'
key: 'status.test'
}, { force: true });
res.end(JSON.stringify(result));
return;
Expand Down
Loading
Loading