chore(auto): task completed - unknown
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
incrementCounter,
|
||||||
|
setGauge,
|
||||||
|
startMetricsServer,
|
||||||
|
stopMetricsServer,
|
||||||
|
} from "./metrics.js";
|
||||||
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = new Map<string, Metric>();
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function key(name: string, labels?: Record<string, string>): 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<string, string>,
|
||||||
|
): 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<string, string>,
|
||||||
|
): 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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { triggerWebhook } from "./webhookNotifier.js";
|
||||||
|
export type { WebhookPayload } from "./webhookNotifier.js";
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user