diff --git a/bot/docs/ADDING_METRICS.md b/bot/docs/ADDING_METRICS.md new file mode 100644 index 0000000..8c971b3 --- /dev/null +++ b/bot/docs/ADDING_METRICS.md @@ -0,0 +1,166 @@ +# Adding New Metrics to AlphaGameBot + +This guide explains how to add new metrics to the AlphaGameBot metrics system. + +## Overview + +The metrics system has been designed to be scalable and easy to extend. Adding a new metric now requires changes in only 2 places instead of 5+. + +## Steps to Add a New Metric + +### 1. Define the Metric Type in the Metrics Enum + +First, add your new metric to the `Metrics` enum in `src/services/metrics/metrics.ts`: + +```typescript +export enum Metrics { + // ... existing metrics ... + MY_NEW_METRIC = "my_new_metric" +} +``` + +### 2. Add the Data Shape to MetricDataMap + +Define the data structure for your metric in `src/interfaces/metrics/MetricDataMap.ts`: + +```typescript +export interface MetricDataMap { + // ... existing metrics ... + [Metrics.MY_NEW_METRIC]: { + field1: string, + field2: number + } +} +``` + +### 3. Add the Metric Configuration + +Add a configuration object to the `metricConfigurations` array in `src/services/metrics/definitions/metricConfigurations.ts`: + +```typescript +{ + name: Metrics.MY_NEW_METRIC, + description: "Description of what this metric tracks", + prometheusType: PrometheusMetricType.GAUGE, // or COUNTER or HISTOGRAM + prometheusName: "alphagamebot_my_new_metric", + prometheusHelp: "Help text for Prometheus", + prometheusLabels: ["label1", "label2"], // Optional labels + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.MY_NEW_METRIC]; + (metric as Gauge).inc({ + label1: String(typedData.field1), + label2: String(typedData.field2) + }); + } +} +``` + +**That's it!** The metric will be automatically: +- Registered with the metric registry +- Created as a Prometheus metric (Gauge/Counter/Histogram) +- Registered with the Prometheus registry +- Processed during metrics export + +## Metric Configuration Options + +### `prometheusType` + +Choose the appropriate Prometheus metric type: + +- **`GAUGE`**: For values that can go up and down (e.g., queue length, latency) +- **`COUNTER`**: For values that only increase (e.g., request count, error count) +- **`HISTOGRAM`**: For distributions (e.g., request duration, database query time) + +### `prometheusLabels` + +Labels allow you to add dimensions to your metrics. For example, a request counter might have labels for `method` and `status_code`. + +### `prometheusBuckets` (Histogram only) + +For histogram metrics, you can define custom buckets: + +```typescript +prometheusBuckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5] +``` + +### `processData` + +This function determines how the metric data is processed and recorded in Prometheus: + +- **Gauge**: Use `.set()` to set a value, `.inc()` to increment +- **Counter**: Use `.inc()` to increment +- **Histogram**: Use `.observe()` to record an observation + +## Using Your Metric + +Once defined, submit metrics using the existing API: + +```typescript +import { metricsManager, Metrics } from "./services/metrics/metrics.js"; + +metricsManager.submitMetric(Metrics.MY_NEW_METRIC, { + field1: "value1", + field2: 42 +}); +``` + +## Benefits of the New System + +1. **Centralized Configuration**: All metric definitions in one place +2. **Type Safety**: TypeScript ensures data matches expected shape +3. **Self-Documenting**: Configuration includes description and help text +4. **No Boilerplate**: No need to update multiple switch statements +5. **Easier Maintenance**: Changes to metric handling only in configuration +6. **Automatic Registration**: Metrics auto-register with Prometheus + +## Example: Adding a Cache Hit/Miss Metric + +### Step 1: Add to Metrics enum + +```typescript +export enum Metrics { + // ... + CACHE_ACCESS = "cache_access" +} +``` + +### Step 2: Add to MetricDataMap + +```typescript +export interface MetricDataMap { + // ... + [Metrics.CACHE_ACCESS]: { + cacheKey: string, + hit: boolean + } +} +``` + +### Step 3: Add configuration + +```typescript +{ + name: Metrics.CACHE_ACCESS, + description: "Cache access hits and misses", + prometheusType: PrometheusMetricType.COUNTER, + prometheusName: "alphagamebot_cache_access_total", + prometheusHelp: "Total number of cache accesses", + prometheusLabels: ["cache_key", "result"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.CACHE_ACCESS]; + (metric as Counter).inc({ + cache_key: String(typedData.cacheKey), + result: typedData.hit ? "hit" : "miss" + }); + } +} +``` + +### Step 4: Use it + +```typescript +metricsManager.submitMetric(Metrics.CACHE_ACCESS, { + cacheKey: "user:123", + hit: true +}); +``` diff --git a/bot/package.json b/bot/package.json index 11778ad..b35a9bf 100644 --- a/bot/package.json +++ b/bot/package.json @@ -1,7 +1,7 @@ { "name": "alphagamebot", "type": "module", - "version": "4.3.4", + "version": "4.4.0", "description": "A Discord bot that's free and (hopefully) doesn't suck.", "main": "index.js", "scripts": { diff --git a/bot/src/interfaces/metrics/MetricConfiguration.ts b/bot/src/interfaces/metrics/MetricConfiguration.ts new file mode 100644 index 0000000..899eeca --- /dev/null +++ b/bot/src/interfaces/metrics/MetricConfiguration.ts @@ -0,0 +1,61 @@ +// This file is a part of AlphaGameBot. +// +// AlphaGameBot - A Discord bot that's free and (hopefully) doesn't suck. +// Copyright (C) 2025 Damien Boisvert (AlphaGameDeveloper) +// +// AlphaGameBot is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AlphaGameBot is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with AlphaGameBot. If not, see . + +import type { Counter, Gauge, Histogram } from "prom-client"; + +/** + * Types of Prometheus metrics supported + */ +export enum PrometheusMetricType { + GAUGE = "gauge", + COUNTER = "counter", + HISTOGRAM = "histogram" +} + +/** + * Configuration for a metric type + */ +export interface MetricConfiguration { + /** Unique identifier for this metric */ + name: string; + + /** Human-readable description */ + description: string; + + /** Type of Prometheus metric */ + prometheusType: PrometheusMetricType; + + /** Prometheus metric name */ + prometheusName: string; + + /** Prometheus help text */ + prometheusHelp: string; + + /** Label names for Prometheus */ + prometheusLabels?: string[]; + + /** Histogram buckets (only used if prometheusType is HISTOGRAM) */ + prometheusBuckets?: number[]; + + /** + * Function to process metric data and update Prometheus metric + * @param metric The Prometheus metric instance (Gauge, Counter, or Histogram) + * @param data The metric data to process + */ + processData: (metric: Gauge | Counter | Histogram, data: unknown) => void; +} diff --git a/bot/src/services/metrics/MetricRegistry.test.ts b/bot/src/services/metrics/MetricRegistry.test.ts new file mode 100644 index 0000000..9e8a72b --- /dev/null +++ b/bot/src/services/metrics/MetricRegistry.test.ts @@ -0,0 +1,118 @@ +// This file is a part of AlphaGameBot. +// +// AlphaGameBot - A Discord bot that's free and (hopefully) doesn't suck. +// Copyright (C) 2025 Damien Boisvert (AlphaGameDeveloper) +// +// AlphaGameBot is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AlphaGameBot is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with AlphaGameBot. If not, see . + +import { jest } from "@jest/globals"; +import { PrometheusMetricType } from "../../interfaces/metrics/MetricConfiguration.js"; +import { MetricRegistry } from "./MetricRegistry.js"; + +describe("MetricRegistry", () => { + let registry: MetricRegistry; + + beforeEach(() => { + registry = new MetricRegistry(); + }); + + it("should register a metric configuration", () => { + const config = { + name: "test_metric", + description: "Test metric", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "test_metric", + prometheusHelp: "Test metric", + processData: jest.fn() + }; + + registry.register(config); + + expect(registry.has("test_metric")).toBe(true); + expect(registry.get("test_metric")).toBe(config); + }); + + it("should warn when registering a duplicate metric", () => { + const config1 = { + name: "test_metric", + description: "Test metric 1", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "test_metric", + prometheusHelp: "Test metric", + processData: jest.fn() + }; + + const config2 = { + name: "test_metric", + description: "Test metric 2", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "test_metric", + prometheusHelp: "Test metric", + processData: jest.fn() + }; + + registry.register(config1); + registry.register(config2); + + // Second config should overwrite the first + expect(registry.get("test_metric")).toBe(config2); + }); + + it("should return undefined for non-existent metric", () => { + expect(registry.get("non_existent")).toBeUndefined(); + }); + + it("should return all registered configurations", () => { + const config1 = { + name: "metric1", + description: "Metric 1", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "metric1", + prometheusHelp: "Metric 1", + processData: jest.fn() + }; + + const config2 = { + name: "metric2", + description: "Metric 2", + prometheusType: PrometheusMetricType.COUNTER, + prometheusName: "metric2", + prometheusHelp: "Metric 2", + processData: jest.fn() + }; + + registry.register(config1); + registry.register(config2); + + const all = registry.getAll(); + expect(all.size).toBe(2); + expect(all.get("metric1")).toBe(config1); + expect(all.get("metric2")).toBe(config2); + }); + + it("should check if a metric is registered", () => { + const config = { + name: "test_metric", + description: "Test metric", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "test_metric", + prometheusHelp: "Test metric", + processData: jest.fn() + }; + + expect(registry.has("test_metric")).toBe(false); + registry.register(config); + expect(registry.has("test_metric")).toBe(true); + }); +}); diff --git a/bot/src/services/metrics/MetricRegistry.ts b/bot/src/services/metrics/MetricRegistry.ts new file mode 100644 index 0000000..33794be --- /dev/null +++ b/bot/src/services/metrics/MetricRegistry.ts @@ -0,0 +1,71 @@ +// This file is a part of AlphaGameBot. +// +// AlphaGameBot - A Discord bot that's free and (hopefully) doesn't suck. +// Copyright (C) 2025 Damien Boisvert (AlphaGameDeveloper) +// +// AlphaGameBot is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AlphaGameBot is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with AlphaGameBot. If not, see . + +import type { MetricConfiguration } from "../../interfaces/metrics/MetricConfiguration.js"; +import { getLogger, LoggerNames } from "../../utility/logging/logger.js"; + +const logger = getLogger(LoggerNames.METRICS); + +/** + * Central registry for all metric configurations. + * This allows metrics to be self-describing and automatically processed. + */ +export class MetricRegistry { + private configurations = new Map(); + + /** + * Register a metric configuration + * @param config The metric configuration to register + */ + register(config: MetricConfiguration): void { + if (this.configurations.has(config.name)) { + logger.warn(`Metric configuration for "${config.name}" is already registered. Overwriting.`); + } + + this.configurations.set(config.name, config); + logger.verbose(`Registered metric configuration: ${config.name}`); + } + + /** + * Get a metric configuration by name + * @param name The metric name + * @returns The metric configuration, or undefined if not found + */ + get(name: string): MetricConfiguration | undefined { + return this.configurations.get(name); + } + + /** + * Get all registered metric configurations + * @returns All metric configurations + */ + getAll(): Map { + return new Map(this.configurations); + } + + /** + * Check if a metric is registered + * @param name The metric name + * @returns True if the metric is registered + */ + has(name: string): boolean { + return this.configurations.has(name); + } +} + +export const metricRegistry = new MetricRegistry(); diff --git a/bot/src/services/metrics/definitions/metricConfigurations.ts b/bot/src/services/metrics/definitions/metricConfigurations.ts new file mode 100644 index 0000000..bac8074 --- /dev/null +++ b/bot/src/services/metrics/definitions/metricConfigurations.ts @@ -0,0 +1,207 @@ +// This file is a part of AlphaGameBot. +// +// AlphaGameBot - A Discord bot that's free and (hopefully) doesn't suck. +// Copyright (C) 2025 Damien Boisvert (AlphaGameDeveloper) +// +// AlphaGameBot is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// AlphaGameBot is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with AlphaGameBot. If not, see . + +import type { Gauge, Histogram } from "prom-client"; +import type { MetricDataMap } from "../../../interfaces/metrics/MetricDataMap.js"; +import { type MetricConfiguration, PrometheusMetricType } from "../../../interfaces/metrics/MetricConfiguration.js"; +import { Metrics } from "../metrics.js"; + +/** + * Metric configurations for all supported metrics. + * Adding a new metric only requires adding a new configuration here. + */ +export const metricConfigurations: MetricConfiguration[] = [ + { + name: Metrics.INTERACTIONS_RECEIVED, + description: "Number of interactions received", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_interactions_received", + prometheusHelp: "Number of interactions received", + prometheusLabels: ["event"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.INTERACTIONS_RECEIVED]; + (metric as Gauge).inc({ event: String(typedData.event) }); + } + }, + { + name: Metrics.EVENT_EXECUTED, + description: "Duration of event execution", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_event_executed_duration_ms", + prometheusHelp: "Duration of event execution in ms", + prometheusLabels: ["event", "eventFile"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.EVENT_EXECUTED]; + (metric as Gauge).set( + { event: String(typedData.event), eventFile: String(typedData.eventFile) }, + Number(typedData.durationMs) + ); + } + }, + { + name: Metrics.COMMAND_EXECUTED, + description: "Duration of command execution", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_command_executed_duration_ms", + prometheusHelp: "Duration of command execution in ms", + prometheusLabels: ["event", "commandName"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.COMMAND_EXECUTED]; + (metric as Gauge).set( + { event: String(typedData.event), commandName: String(typedData.commandName) }, + Number(typedData.durationMs) + ); + } + }, + { + name: Metrics.RAW_EVENT_RECEIVED, + description: "Number of raw events received", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_raw_event_received", + prometheusHelp: "Number of raw events received", + prometheusLabels: ["event"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.RAW_EVENT_RECEIVED]; + (metric as Gauge).inc({ event: String(typedData.event) }); + } + }, + { + name: Metrics.METRICS_QUEUE_LENGTH, + description: "Current length of the metrics queue", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_metrics_queue_length", + prometheusHelp: "Current length of the metrics queue", + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.METRICS_QUEUE_LENGTH]; + (metric as Gauge).set(Number(typedData.length)); + } + }, + { + name: Metrics.METRICS_QUEUE_LENGTH_BY_METRIC, + description: "Current length of the metrics queue by metric", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_metrics_queue_length_by_metric", + prometheusHelp: "Current length of the metrics queue by metric", + prometheusLabels: ["metric"], + processData: () => { + // This metric is handled specially in the exporter + } + }, + { + name: Metrics.METRICS_GENERATION_TIME, + description: "Time taken to generate metrics", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_metrics_generation_time_ms", + prometheusHelp: "Time taken to generate metrics in ms", + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.METRICS_GENERATION_TIME]; + (metric as Gauge).set(Number(typedData.durationMs)); + } + }, + { + name: Metrics.EVENT_RECEIVED, + description: "Events received", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_event_received", + prometheusHelp: "Events Received", + prometheusLabels: ["event"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.EVENT_RECEIVED]; + (metric as Gauge).inc({ event: String(typedData.event) }); + } + }, + { + name: Metrics.DISCORD_LATENCY, + description: "Discord API latency", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_discord_latency_ms", + prometheusHelp: "Discord API Latency in ms", + processData: () => { + // This metric is handled specially in the exporter + } + }, + { + name: Metrics.APPLICATION_ERROR, + description: "Number of application errors", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_application_error", + prometheusHelp: "Number of application errors", + prometheusLabels: ["event"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.APPLICATION_ERROR]; + (metric as Gauge).inc({ event: String(typedData.name) }); + } + }, + { + name: Metrics.INTERACTION_RECEIVED, + description: "Number of interactions received by type", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_interaction_received", + prometheusHelp: "Number of interactions received by type", + prometheusLabels: ["interactionType"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.INTERACTION_RECEIVED]; + (metric as Gauge).inc({ interactionType: String(typedData.interactionType) }); + } + }, + { + name: Metrics.FEATURE_USED, + description: "Features used", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_feature_used", + prometheusHelp: "Features Used", + prometheusLabels: ["feature"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.FEATURE_USED]; + (metric as Gauge).inc({ feature: String(typedData.feature) }); + } + }, + { + name: Metrics.METRICS_HTTP_SERVER_REQUESTS, + description: "HTTP server requests for metrics", + prometheusType: PrometheusMetricType.GAUGE, + prometheusName: "alphagamebot_metrics_http_server_requests", + prometheusHelp: "HTTP server requests for metrics", + prometheusLabels: ["method", "url", "remoteAddress", "statusCode"], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.METRICS_HTTP_SERVER_REQUESTS]; + (metric as Gauge).inc({ + method: String(typedData.method), + url: String(typedData.url), + remoteAddress: String(typedData.remoteAddress), + statusCode: String(typedData.statusCode) + }); + } + }, + { + name: Metrics.DATABASE_OPERATION, + description: "Database operation duration", + prometheusType: PrometheusMetricType.HISTOGRAM, + prometheusName: "alphagamebot_database_operation_duration_seconds", + prometheusHelp: "Database operation duration in seconds", + prometheusLabels: ["model", "operation"], + prometheusBuckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5], + processData: (metric, data) => { + const typedData = data as MetricDataMap[Metrics.DATABASE_OPERATION]; + (metric as Histogram).observe( + { model: String(typedData.model), operation: String(typedData.operation) }, + Number(typedData.durationMs) / 1000 + ); + } + } +]; diff --git a/bot/src/services/metrics/exports/prometheus.ts b/bot/src/services/metrics/exports/prometheus.ts index b873b14..0e30a52 100644 --- a/bot/src/services/metrics/exports/prometheus.ts +++ b/bot/src/services/metrics/exports/prometheus.ts @@ -16,11 +16,14 @@ // You should have received a copy of the GNU General Public License // along with AlphaGameBot. If not, see . -import { collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client"; +import { collectDefaultMetrics, Counter, Gauge, Histogram, Registry } from "prom-client"; import { client } from "../../../client.js"; +import { PrometheusMetricType } from "../../../interfaces/metrics/MetricConfiguration.js"; import { formatTime } from "../../../utility/formatTime.js"; import { getLogger } from "../../../utility/logging/logger.js"; +import { metricConfigurations } from "../definitions/metricConfigurations.js"; import { Metrics, metricsManager } from "../metrics.js"; +import { metricRegistry } from "../MetricRegistry.js"; import { MetricsHTTPServer } from "./http/MetricsHTTPServer.js"; const registry = new Registry(); @@ -28,208 +31,113 @@ collectDefaultMetrics({ register: registry, prefix: "alphagamebot_" }); const logger = getLogger("prometheus"); const METRICS_HTTP_SERVER_PORT = process.env.METRICS_HTTP_SERVER_PORT || "9100"; -type MetricTypeMap = { - [Metrics.INTERACTIONS_RECEIVED]: Gauge, - [Metrics.EVENT_EXECUTED]: Gauge, - [Metrics.COMMAND_EXECUTED]: Gauge, - [Metrics.RAW_EVENT_RECEIVED]: Gauge, - [Metrics.METRICS_QUEUE_LENGTH]: Gauge, - [Metrics.METRICS_QUEUE_LENGTH_BY_METRIC]: Gauge, - [Metrics.METRICS_GENERATION_TIME]: Gauge, - [Metrics.EVENT_RECEIVED]: Gauge, - [Metrics.DISCORD_LATENCY]: Gauge, - [Metrics.APPLICATION_ERROR]: Gauge, - [Metrics.INTERACTION_RECEIVED]: Gauge, - [Metrics.FEATURE_USED]: Gauge, - [Metrics.METRICS_HTTP_SERVER_REQUESTS]: Gauge, - [Metrics.DATABASE_OPERATION]: Histogram +// Register all metric configurations +metricConfigurations.forEach(config => metricRegistry.register(config)); + +// Create Prometheus metrics dynamically from configurations +const prometheusMetrics = new Map(); + +for (const config of metricConfigurations) { + let metric: Gauge | Counter | Histogram; + + switch (config.prometheusType) { + case PrometheusMetricType.GAUGE: + metric = new Gauge({ + name: config.prometheusName, + help: config.prometheusHelp, + labelNames: config.prometheusLabels || [] + }); + break; + case PrometheusMetricType.COUNTER: + metric = new Counter({ + name: config.prometheusName, + help: config.prometheusHelp, + labelNames: config.prometheusLabels || [] + }); + break; + case PrometheusMetricType.HISTOGRAM: + metric = new Histogram({ + name: config.prometheusName, + help: config.prometheusHelp, + labelNames: config.prometheusLabels || [], + ...(config.prometheusBuckets ? { buckets: config.prometheusBuckets } : {}) + }); + break; + default: + throw new Error(`Unknown Prometheus metric type: ${config.prometheusType}`); + } + + prometheusMetrics.set(config.name, metric); + registry.registerMetric(metric); } -// Define gauges for each metric type -const gauges: { [K in keyof MetricTypeMap]: MetricTypeMap[K] } = { - [Metrics.INTERACTIONS_RECEIVED]: new Gauge({ - name: "alphagamebot_interactions_received", - help: "Number of interactions received", - labelNames: ["event"] - }), - [Metrics.EVENT_EXECUTED]: new Gauge({ - name: "alphagamebot_event_executed_duration_ms", - help: "Duration of event execution in ms", - labelNames: ["event", "eventFile"] - }), - [Metrics.COMMAND_EXECUTED]: new Gauge({ - name: "alphagamebot_command_executed_duration_ms", - help: "Duration of command execution in ms", - labelNames: ["event", "commandName"] - }), - [Metrics.RAW_EVENT_RECEIVED]: new Gauge({ - name: "alphagamebot_raw_event_received", - help: "Number of raw events received", - labelNames: ["event"] - }), - [Metrics.METRICS_QUEUE_LENGTH]: new Gauge({ - name: "alphagamebot_metrics_queue_length", - help: "Current length of the metrics queue" - }), - [Metrics.METRICS_QUEUE_LENGTH_BY_METRIC]: new Gauge({ - name: "alphagamebot_metrics_queue_length_by_metric", - help: "Current length of the metrics queue by metric", - labelNames: ["metric"] - }), - [Metrics.METRICS_GENERATION_TIME]: new Gauge({ - name: "alphagamebot_metrics_generation_time_ms", - help: "Time taken to generate metrics in ms" - }), - [Metrics.EVENT_RECEIVED]: new Gauge({ - name: "alphagamebot_event_received", - help: "Events Received", - labelNames: ["event"] - }), - [Metrics.DISCORD_LATENCY]: new Gauge({ - name: "alphagamebot_discord_latency_ms", - help: "Discord API Latency in ms" - }), - [Metrics.APPLICATION_ERROR]: new Gauge({ - name: "alphagamebot_application_error", - help: "Number of application errors", - labelNames: ["event"] - }), - [Metrics.INTERACTION_RECEIVED]: new Gauge({ - name: "alphagamebot_interaction_received", - help: "Number of interactions received by type", - labelNames: ["interactionType"] - }), - [Metrics.FEATURE_USED]: new Gauge({ - name: "alphagamebot_feature_used", - help: "Features Used", - labelNames: ["feature"] - }), - [Metrics.METRICS_HTTP_SERVER_REQUESTS]: new Gauge({ - name: "alphagamebot_metrics_http_server_requests", - help: "HTTP server requests for metrics", - labelNames: ["method", "url", "remoteAddress", "statusCode"] - }), - [Metrics.DATABASE_OPERATION]: new Histogram({ - name: "alphagamebot_database_operation_duration_seconds", - help: "Database operation duration in seconds", - labelNames: ["model", "operation"], - buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5] - }), -}; - -Object.values(gauges).forEach(g => registry.registerMetric(g)); - async function exportMetricsToPrometheus() { - // Clear previous gauge values const startTime = performance.now(); - Object.values(gauges).forEach(g => g.reset()); + + // Reset all metrics + prometheusMetrics.forEach(metric => metric.reset()); + logger.verbose("Exporting metrics..."); let queueLength = 0; - let someMetricsFailed = false; const queueLengthByMetric: Map = new Map(); const metricsMap = metricsManager.getMetrics(); - for (const [metric, entries] of metricsMap.entries()) { - queueLengthByMetric.set(metric, entries.length); - gauges[Metrics.METRICS_QUEUE_LENGTH_BY_METRIC].set({ metric: metric }, entries.length); - logger.verbose(`Processing ${entries.length} entries for metric ${metric}`); + + for (const [metricName, entries] of metricsMap.entries()) { + queueLengthByMetric.set(metricName, entries.length); + + // Handle the special queue length by metric counter + const queueLengthByMetricGauge = prometheusMetrics.get(Metrics.METRICS_QUEUE_LENGTH_BY_METRIC) as Gauge; + if (queueLengthByMetricGauge) { + queueLengthByMetricGauge.set({ metric: metricName }, entries.length); + } + + logger.verbose(`Processing ${entries.length} entries for metric ${metricName}`); + + const config = metricRegistry.get(metricName); + if (!config) { + logger.error(`No configuration found for metric: ${metricName}`); + continue; + } + + const prometheusMetric = prometheusMetrics.get(metricName); + if (!prometheusMetric) { + logger.error(`No Prometheus metric found for: ${metricName}`); + continue; + } + for (const entry of entries) { queueLength++; const metricEntry = entry as { data: unknown }; - const data = (metricEntry.data ?? {}) as Record; - - switch (metric) { - case Metrics.INTERACTIONS_RECEIVED: { - (gauges[metric] as Gauge).inc({ event: String(data.event) }); - break; - } - - case Metrics.EVENT_EXECUTED: { - (gauges[metric] as Gauge).set({ event: String(data.event) }, Number(data.durationMs)); - break; - } - - case Metrics.COMMAND_EXECUTED: { - (gauges[metric] as Gauge).set({ event: String(data.event), commandName: String(data.commandName) }, Number(data.durationMs)); - break; - } - - case Metrics.RAW_EVENT_RECEIVED: { - (gauges[metric] as Gauge).inc({ event: String(data.event) }); - break; - } - - case Metrics.METRICS_QUEUE_LENGTH: { - // Handled after the loop - break; - } - - case Metrics.METRICS_GENERATION_TIME: { - // Handled after the loop - break; - } - - case Metrics.EVENT_RECEIVED: { - (gauges[metric] as Gauge).inc({ event: String(data.event) }); - break; - } - - case Metrics.APPLICATION_ERROR: { - (gauges[metric] as Gauge).inc({ event: String(data.event) }); - break; - } - - case Metrics.INTERACTION_RECEIVED: { - (gauges[metric] as Gauge).inc({ interactionType: String(data.interactionType) }); - break; - } - - case Metrics.FEATURE_USED: { - (gauges[metric] as Gauge).inc({ feature: String(data.feature) }); - break; - } - - case Metrics.METRICS_HTTP_SERVER_REQUESTS: { - (gauges[metric] as Gauge).inc({ - method: String(data.method), - url: String(data.url), - remoteAddress: String(data.remoteAddress), - statusCode: String(data.statusCode) - }); - break; - } - - case Metrics.DATABASE_OPERATION: { - (gauges[metric] as Histogram).observe( - { model: String(data.model), operation: String(data.operation) }, - Number(data.durationMs) / 1000 - ); - break; - } - - default: { - // Exhaustiveness check - using the variable to avoid "assigned but never used" - const _exhaustive: never = metric; - void _exhaustive; - someMetricsFailed = true; - logger.error(`No exporter defined for metric type: ${String(metric)}! This should never happen!`); - break; - } + const data = metricEntry.data; + + try { + config.processData(prometheusMetric, data); + } catch (error) { + logger.error(`Error processing metric ${metricName}:`, error); } } } - if (someMetricsFailed) logger.error("Some metrics failed to export due to missing gauge/histogram definitions."); - const durationMs = performance.now() - startTime; logger.verbose(`Metrics generation took ${durationMs}ms, queue length is ${queueLength}`); + // Set special metrics that aren't based on queue entries + const metricsGenerationTimeGauge = prometheusMetrics.get(Metrics.METRICS_GENERATION_TIME) as Gauge; + if (metricsGenerationTimeGauge) { + metricsGenerationTimeGauge.set(durationMs); + } + + const metricsQueueLengthGauge = prometheusMetrics.get(Metrics.METRICS_QUEUE_LENGTH) as Gauge; + if (metricsQueueLengthGauge) { + metricsQueueLengthGauge.set(queueLength); + } + + const discordLatencyGauge = prometheusMetrics.get(Metrics.DISCORD_LATENCY) as Gauge; + if (discordLatencyGauge) { + discordLatencyGauge.set(client.ws.ping); + } - gauges[Metrics.METRICS_GENERATION_TIME].set(durationMs); - gauges[Metrics.METRICS_QUEUE_LENGTH].set(queueLength); - gauges[Metrics.DISCORD_LATENCY].set(client.ws.ping); - - // return data for prometheus, as a string return await registry.metrics(); } diff --git a/package-lock.json b/package-lock.json index 6ff54c0..301e66f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "bot": { "name": "alphagamebot", - "version": "4.3.3", + "version": "4.3.4", "license": "GPL-3.0-or-later", "dependencies": { "@octokit/rest": "^22.0.1",