Skip to content
Draft
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
6 changes: 5 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ jobs:
env:
NODE_ENV: test
with:
test-script: node --loader ts-node/esm --no-warnings=ExperimentalWarning --experimental-vm-modules node_modules/jest/bin/jest.js bot/src/ --forceExit --silent
# Use --prefix to ensure the test runner runs in the `bot` package regardless
# of the action's working directory handling. Using `-w` can behave differently
# inside actions which may cause path-related code (like the crawler) to look
# in the wrong project root.
test-script: npm --prefix ./bot test -- --coverage --forceExit --silent
github-token: ${{ secrets.GITHUB_TOKEN }}
output: comment, report-markdown
166 changes: 166 additions & 0 deletions bot/docs/ADDING_METRICS.md
Original file line number Diff line number Diff line change
@@ -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>(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>(Metrics.CACHE_ACCESS, {
cacheKey: "user:123",
hit: true
});
```
61 changes: 61 additions & 0 deletions bot/src/interfaces/metrics/MetricConfiguration.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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;
}
118 changes: 118 additions & 0 deletions bot/src/services/metrics/MetricRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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);
});
});
Loading
Loading