diff --git a/services/discord-gateway/src/modules/gateway-metrics/index.ts b/services/discord-gateway/src/modules/gateway-metrics/index.ts new file mode 100644 index 0000000..de6b719 --- /dev/null +++ b/services/discord-gateway/src/modules/gateway-metrics/index.ts @@ -0,0 +1,6 @@ +export { + incrementCounter, + setGauge, + startMetricsServer, + stopMetricsServer, +} from "./metrics.js"; diff --git a/services/discord-gateway/src/modules/gateway-metrics/metrics.ts b/services/discord-gateway/src/modules/gateway-metrics/metrics.ts new file mode 100644 index 0000000..58f1a89 --- /dev/null +++ b/services/discord-gateway/src/modules/gateway-metrics/metrics.ts @@ -0,0 +1,115 @@ +import http from "node:http"; +import { createChildLogger } from "@bete/shared/logger"; +import { config } from "../../shared/config/config.js"; + +const logger = createChildLogger("gateway-metrics"); + +// ─── Metrics Store ─────────────────────────────────────────────────────── + +interface Metric { + help: string; + type: "counter" | "gauge"; + value: number; + labels?: Record; +} + +const metrics = new Map(); + +// ─── Helpers ───────────────────────────────────────────────────────────── + +function key(name: string, labels?: Record): string { + if (!labels) return name; + const labelStr = Object.entries(labels) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}="${v}"`) + .join(","); + return `${name}{${labelStr}}`; +} + +// ─── Public API ────────────────────────────────────────────────────────── + +export function incrementCounter( + name: string, + labels?: Record, +): void { + const k = key(`bete_${name}`, labels); + const existing = metrics.get(k); + if (existing) { + existing.value += 1; + } else { + metrics.set(k, { + help: `Counter: ${name}`, + type: "counter", + value: 1, + labels: labels ? { ...labels } : undefined, + }); + } +} + +export function setGauge( + name: string, + value: number, + labels?: Record, +): void { + const k = key(`bete_${name}`, labels); + const existing = metrics.get(k); + if (existing) { + existing.value = value; + } else { + metrics.set(k, { + help: `Gauge: ${name}`, + type: "gauge", + value, + labels: labels ? { ...labels } : undefined, + }); + } +} + +// ─── HTTP Server ───────────────────────────────────────────────────────── + +let server: http.Server | null = null; + +function formatMetrics(): string { + const lines: string[] = []; + + for (const [fullName, metric] of metrics) { + const baseName = fullName.includes("{") ? fullName.slice(0, fullName.indexOf("{")) : fullName; + lines.push(`# HELP ${baseName} ${metric.help}`); + lines.push(`# TYPE ${baseName} ${metric.type}`); + lines.push(`${fullName} ${metric.value}`); + } + + return lines.join("\n") + "\n"; +} + +export function startMetricsServer(): void { + if (server) return; + + const port = config.METRICS_PORT; + logger.info({ port }, "Starting metrics HTTP server"); + + server = http.createServer((req, res) => { + if (req.url === "/metrics" || req.url === "/health") { + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(formatMetrics()); + } else { + res.writeHead(404); + res.end("Not found"); + } + }); + + server.listen(port, () => { + logger.info({ port }, "Metrics server listening"); + }); + + server.on("error", (err) => { + logger.error({ error: err.message }, "Metrics server error"); + }); +} + +export function stopMetricsServer(): void { + if (!server) return; + server.close(); + server = null; + logger.info("Metrics server stopped"); +} diff --git a/services/discord-gateway/src/modules/webhook-notifications/index.ts b/services/discord-gateway/src/modules/webhook-notifications/index.ts new file mode 100644 index 0000000..a1509f3 --- /dev/null +++ b/services/discord-gateway/src/modules/webhook-notifications/index.ts @@ -0,0 +1,2 @@ +export { triggerWebhook } from "./webhookNotifier.js"; +export type { WebhookPayload } from "./webhookNotifier.js"; diff --git a/services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts b/services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts new file mode 100644 index 0000000..936fdc6 --- /dev/null +++ b/services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts @@ -0,0 +1,91 @@ +import { createChildLogger } from "@bete/shared/logger"; +import { config } from "../../shared/config/config.js"; + +const logger = createChildLogger("webhook-notifier"); + +// ─── Types ─────────────────────────────────────────────────────────────── + +export interface WebhookPayload { + event: string; + timestamp: number; + guild_id?: string | null; + channel_id?: string | null; + message_id?: string | null; + user_id?: string | null; + username?: string | null; + severity?: string | null; + flags?: string[] | null; + content?: string | null; + details?: Record; +} + +// ─── Public API ────────────────────────────────────────────────────────── + +/** + * Fire a webhook notification to all configured URLs. + * Fire-and-forget: errors are logged, never thrown. + */ +export async function triggerWebhook( + eventType: string, + payload: WebhookPayload, +): Promise { + const urls = config.WEBHOOK_URLS; + if (!urls || urls.length === 0) return; + + const enabledEvents = config.WEBHOOK_EVENTS; + if (enabledEvents.length > 0 && !enabledEvents.includes(eventType)) return; + + const body = JSON.stringify({ + ...payload, + event: eventType, + timestamp: Date.now(), + source: "discord-gateway", + }); + + const results = await Promise.allSettled( + urls.map((url) => sendWebhook(url, body)), + ); + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === "rejected") { + logger.warn( + { url: urls[i], eventType, error: String(result.reason) }, + "Webhook delivery failed", + ); + } + } +} + +// ─── Internal ──────────────────────────────────────────────────────────── + +async function sendWebhook(url: string, body: string): Promise { + let lastErr: Error | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + throw new Error(`Webhook responded with status ${response.status}`); + } + + logger.debug({ url }, "Webhook delivered"); + return; + } catch (err) { + lastErr = err instanceof Error ? err : new Error(String(err)); + if (attempt < 2) { + // Brief backoff before retry + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } + } + } + + throw lastErr ?? new Error("Webhook send failed after retries"); +}