diff --git a/package-lock.json b/package-lock.json
index b571b0c..6ad371d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15865,9 +15865,9 @@
}
},
"node_modules/winston": {
- "version": "3.18.3",
- "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz",
- "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==",
+ "version": "3.19.0",
+ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
+ "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"dependencies": {
"@colors/colors": "^1.6.0",
@@ -16499,12 +16499,15 @@
"marked": "^4.3.0",
"next": "^16.0.7",
"prisma": "^6.19.0",
+ "prom-client": "^15.1.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
- "remark-gfm": "^4.0.1"
+ "remark-gfm": "^4.0.1",
+ "winston": "^3.19.0",
+ "winston-transport": "^4.9.0"
},
"devDependencies": {
"@prisma/config": "^6.19.0",
diff --git a/web/app/api/auth/callback/route.ts b/web/app/api/auth/callback/route.ts
index 869d62e..a3943d9 100644
--- a/web/app/api/auth/callback/route.ts
+++ b/web/app/api/auth/callback/route.ts
@@ -3,6 +3,9 @@ import crypto from 'crypto';
import type { User } from 'discord.js';
import { NextRequest, NextResponse } from 'next/server';
import db from '../../../lib/database';
+import { getLogger } from '@/app/lib/logging/logger';
+
+const logger = getLogger('api/auth/callback');
async function fetchToken(code: string) {
const params = new URLSearchParams({
@@ -31,12 +34,17 @@ async function fetchUser(access_token: string) {
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const code = url.searchParams.get('code');
- if (!code) return NextResponse.json({ error: 'No code provided' }, { status: 400 });
+ if (!code) {
+ logger.warn('OAuth callback called without code parameter');
+ return NextResponse.json({ error: 'No code provided' }, { status: 400 });
+ }
try {
+ logger.debug('Fetching OAuth token');
const token = await fetchToken(code);
if (token.error) throw token;
+ logger.debug('Fetching user info from Discord');
const user = await fetchUser(token.access_token);
const res = NextResponse.redirect(new URL(process.env.NEXT_PUBLIC_BASE_URL || '/'));
@@ -45,12 +53,15 @@ export async function GET(req: NextRequest) {
const hashed = hashToken(raw);
const expires = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
+
+ logger.debug(`Upserting user ${user.username} (${user.id}) to database`);
await db.user.upsert({
where: { id: user.id },
update: { username: user.username, discriminator: user.discriminator, last_login: new Date() },
create: { id: user.id, username: user.username, discriminator: user.discriminator }
});
+ logger.debug('Creating session in database');
await db.session.create({
data: {
hashedId: hashed,
@@ -62,8 +73,11 @@ export async function GET(req: NextRequest) {
const cookie = `agb_session=${raw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 7};`;
res.headers.append('Set-Cookie', cookie);
+
+ logger.info(`User ${user.username} (${user.id}) successfully authenticated`);
return res;
} catch (err) {
+ logger.error('OAuth callback failed:', err);
return NextResponse.json({ error: 'OAuth callback failed', detail: JSON.stringify(err) }, { status: 500 });
}
}
diff --git a/web/app/api/metrics/route.ts b/web/app/api/metrics/route.ts
new file mode 100644
index 0000000..17dd683
--- /dev/null
+++ b/web/app/api/metrics/route.ts
@@ -0,0 +1,33 @@
+// Copyright (c) 2025 Damien Boisvert (AlphaGameDeveloper)
+//
+// This software is released under the MIT License.
+// https://opensource.org/licenses/MIT
+
+import { exportMetricsToPrometheus } from "@/app/lib/metrics/exports/prometheus";
+import { getLogger } from "@/app/lib/logging/logger";
+
+const logger = getLogger("api/metrics");
+
+export const runtime = 'nodejs';
+
+export async function GET() {
+ try {
+ logger.debug("Metrics requested");
+ const metrics = await exportMetricsToPrometheus();
+
+ return new Response(metrics, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
+ },
+ });
+ } catch (error) {
+ logger.error("Error collecting metrics:", error);
+ return new Response("Error collecting metrics", {
+ status: 500,
+ headers: {
+ "Content-Type": "text/plain",
+ },
+ });
+ }
+}
diff --git a/web/app/api/system-status/route.ts b/web/app/api/system-status/route.ts
index dd1d659..30d9acf 100644
--- a/web/app/api/system-status/route.ts
+++ b/web/app/api/system-status/route.ts
@@ -4,12 +4,21 @@
// https://opensource.org/licenses/MIT
import client from "@/app/lib/database";
+import { getLogger } from "@/app/lib/logging/logger";
import { NextResponse } from "next/server";
+const logger = getLogger("api/system-status");
+
export async function GET() {
- const ok = await client.$executeRaw`SELECT 1`.catch(() => null);
+ logger.debug("Checking system status");
+ const ok = await client.$executeRaw`SELECT 1`.catch((err: unknown) => {
+ logger.error("Database health check failed:", err);
+ return null;
+ });
if (ok === null) {
+ logger.warn("System status degraded - database check failed");
return NextResponse.json({ status: "degraded" });
}
+ logger.debug("System status operational");
return NextResponse.json({ status: "operational" });
}
\ No newline at end of file
diff --git a/web/app/lib/database.ts b/web/app/lib/database.ts
index 9a5ed9e..fc43c74 100644
--- a/web/app/lib/database.ts
+++ b/web/app/lib/database.ts
@@ -4,6 +4,9 @@
// https://opensource.org/licenses/MIT
import { PrismaClient } from "@prisma/client";
+import { getLogger } from "./logging/logger";
+
+const logger = getLogger("database");
declare global {
interface Window {
@@ -11,6 +14,22 @@ declare global {
}
}
-const client = new PrismaClient();
+const client = new PrismaClient({
+ log: [
+ { level: 'warn', emit: 'event' },
+ { level: 'error', emit: 'event' },
+ ],
+});
+
+// Forward Prisma logs to Winston
+client.$on('warn', (e: { message: string }) => {
+ logger.warn(`Prisma: ${e.message}`);
+});
+
+client.$on('error', (e: { message: string }) => {
+ logger.error(`Prisma: ${e.message}`);
+});
+
+logger.info("Database client initialized");
export default client;
\ No newline at end of file
diff --git a/web/app/lib/interfaces/metrics/MetricConfiguration.ts b/web/app/lib/interfaces/metrics/MetricConfiguration.ts
new file mode 100644
index 0000000..899eeca
--- /dev/null
+++ b/web/app/lib/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/web/app/lib/interfaces/metrics/MetricDataMap.ts b/web/app/lib/interfaces/metrics/MetricDataMap.ts
new file mode 100644
index 0000000..8337a13
--- /dev/null
+++ b/web/app/lib/interfaces/metrics/MetricDataMap.ts
@@ -0,0 +1,54 @@
+// 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 { Metrics } from "../../metrics/metrics";
+
+export interface MetricDataMap {
+ [Metrics.HTTP_REQUEST]: {
+ method: string,
+ path: string,
+ statusCode: number,
+ durationMs: number
+ },
+ [Metrics.API_REQUEST]: {
+ endpoint: string,
+ method: string,
+ statusCode: number,
+ durationMs: number
+ },
+ [Metrics.DATABASE_OPERATION]: {
+ model: string,
+ operation: string,
+ durationMs: number
+ },
+ [Metrics.APPLICATION_ERROR]: {
+ name: string,
+ message: string,
+ stack?: string
+ },
+ [Metrics.METRICS_GENERATION_TIME]: {
+ durationMs: number
+ },
+ [Metrics.METRICS_QUEUE_LENGTH]: {
+ length: number
+ },
+ [Metrics.METRICS_QUEUE_LENGTH_BY_METRIC]: {
+ metric: string,
+ length: number
+ }
+}
diff --git a/web/app/lib/logging/logger.ts b/web/app/lib/logging/logger.ts
new file mode 100644
index 0000000..40be7a1
--- /dev/null
+++ b/web/app/lib/logging/logger.ts
@@ -0,0 +1,102 @@
+// 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 winston, { createLogger, format, type Logger, transports } from "winston";
+
+// Winston's default logger levels are bullshit.
+const logConfig = {
+ levels: {
+ error: 0,
+ warn: 1,
+ info: 2,
+ verbose: 4,
+ debug: 3,
+ silly: 5,
+ },
+ colors: {
+ error: 'red',
+ warn: 'yellow',
+ info: 'green',
+ verbose: 'cyan',
+ debug: 'blue',
+ silly: 'magenta',
+ },
+};
+
+export enum LoggerNames {
+ METRICS = "metrics",
+ WEB = "web",
+ API = "api"
+}
+
+function shouldWeUseColors(): boolean {
+ // Check if we're in a Node.js environment with stdout
+ if (typeof process !== 'undefined' && process.stdout) {
+ return process.stdout.isTTY && !(process.env.NO_COLOR);
+ }
+ return false;
+}
+
+winston.addColors(logConfig.colors);
+
+const rootLogger = createLogger({
+ levels: logConfig.levels,
+ level: process.env.NODE_ENV === "production" ? "info" : (process.env.VERBOSE ? "verbose" : "debug"),
+ // [file:line] [level]: message
+ format: format.combine(
+ shouldWeUseColors() ? format.colorize() : format.uncolorize(),
+ format.timestamp(),
+ format.printf(({ timestamp, level, message, ...metadata }): string => {
+ const shouldIncludeTimestamp = process.env.NODE_ENV === "production";
+
+ let msg = "";
+
+ if (shouldIncludeTimestamp) {
+ msg += `[${timestamp}] `;
+ }
+
+ let levelText = "";
+
+ if (metadata.label) {
+ levelText += `[${metadata.label}/${level}]`;
+ } else {
+ levelText += `[${level}]`;
+ }
+ msg += `${levelText}: ${message}`;
+
+ return msg;
+ })
+ ),
+ transports: [
+ new (transports.Console)({
+ silent: process.env.NODE_ENV === "test"
+ })
+ ]
+});
+
+export function getLogger(name: string, ...options: unknown[]): Logger {
+ return rootLogger.child({ label: name, ...options });
+}
+
+const logger = getLogger("root");
+
+if (typeof process !== 'undefined' && process.stdout && !process.stdout.isTTY) {
+ logger.warn("Output doesn't seem to be a TTY. Several features have been disabled.");
+}
+
+export default logger;
diff --git a/web/app/lib/metrics/MetricRegistry.ts b/web/app/lib/metrics/MetricRegistry.ts
new file mode 100644
index 0000000..194e9b9
--- /dev/null
+++ b/web/app/lib/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";
+import { getLogger, LoggerNames } from "../logging/logger";
+
+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/web/app/lib/metrics/definitions/metricConfigurations.ts b/web/app/lib/metrics/definitions/metricConfigurations.ts
new file mode 100644
index 0000000..68e558f
--- /dev/null
+++ b/web/app/lib/metrics/definitions/metricConfigurations.ts
@@ -0,0 +1,122 @@
+// 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";
+import { type MetricConfiguration, PrometheusMetricType } from "../../interfaces/metrics/MetricConfiguration";
+import { Metrics } from "../metrics";
+
+/**
+ * Metric configurations for all supported metrics.
+ * Adding a new metric only requires adding a new configuration here.
+ */
+export const metricConfigurations: MetricConfiguration[] = [
+ {
+ name: Metrics.HTTP_REQUEST,
+ description: "HTTP requests received by the web server",
+ prometheusType: PrometheusMetricType.HISTOGRAM,
+ prometheusName: "alphagamebot_web_http_request_duration_seconds",
+ prometheusHelp: "HTTP request duration in seconds",
+ prometheusLabels: ["method", "path", "statusCode"],
+ 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.HTTP_REQUEST];
+ (metric as Histogram).observe(
+ { method: String(typedData.method), path: String(typedData.path), statusCode: String(typedData.statusCode) },
+ Number(typedData.durationMs) / 1000
+ );
+ }
+ },
+ {
+ name: Metrics.API_REQUEST,
+ description: "API requests to specific endpoints",
+ prometheusType: PrometheusMetricType.HISTOGRAM,
+ prometheusName: "alphagamebot_web_api_request_duration_seconds",
+ prometheusHelp: "API request duration in seconds",
+ prometheusLabels: ["endpoint", "method", "statusCode"],
+ 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.API_REQUEST];
+ (metric as Histogram).observe(
+ { endpoint: String(typedData.endpoint), method: String(typedData.method), statusCode: String(typedData.statusCode) },
+ Number(typedData.durationMs) / 1000
+ );
+ }
+ },
+ {
+ name: Metrics.DATABASE_OPERATION,
+ description: "Database operation duration",
+ prometheusType: PrometheusMetricType.HISTOGRAM,
+ prometheusName: "alphagamebot_web_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
+ );
+ }
+ },
+ {
+ name: Metrics.APPLICATION_ERROR,
+ description: "Number of application errors",
+ prometheusType: PrometheusMetricType.GAUGE,
+ prometheusName: "alphagamebot_web_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.METRICS_GENERATION_TIME,
+ description: "Time taken to generate metrics",
+ prometheusType: PrometheusMetricType.GAUGE,
+ prometheusName: "alphagamebot_web_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.METRICS_QUEUE_LENGTH,
+ description: "Current length of the metrics queue",
+ prometheusType: PrometheusMetricType.GAUGE,
+ prometheusName: "alphagamebot_web_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_web_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
+ }
+ }
+];
diff --git a/web/app/lib/metrics/exports/prometheus.ts b/web/app/lib/metrics/exports/prometheus.ts
new file mode 100644
index 0000000..f422b2d
--- /dev/null
+++ b/web/app/lib/metrics/exports/prometheus.ts
@@ -0,0 +1,133 @@
+// 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 { collectDefaultMetrics, Counter, Gauge, Histogram, Registry } from "prom-client";
+import { PrometheusMetricType } from "../../interfaces/metrics/MetricConfiguration";
+import { getLogger } from "../../logging/logger";
+import { metricConfigurations } from "../definitions/metricConfigurations";
+import { Metrics, metricsManager } from "../metrics";
+import { metricRegistry } from "../MetricRegistry";
+
+const registry = new Registry();
+collectDefaultMetrics({ register: registry, prefix: "alphagamebot_web_" });
+const logger = getLogger("prometheus");
+
+// 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);
+}
+
+export async function exportMetricsToPrometheus() {
+ const startTime = performance.now();
+
+ // Reset all metrics
+ prometheusMetrics.forEach(metric => metric.reset());
+
+ logger.verbose("Exporting metrics...");
+ let queueLength = 0;
+ const queueLengthByMetric: Map = new Map();
+
+ const metricsMap = metricsManager.getMetrics();
+
+ 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;
+
+ try {
+ config.processData(prometheusMetric, data);
+ } catch (error) {
+ logger.error(`Error processing metric ${metricName}:`, error);
+ }
+ }
+ }
+
+ 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);
+ }
+
+ return await registry.metrics();
+}
diff --git a/web/app/lib/metrics/metrics.ts b/web/app/lib/metrics/metrics.ts
new file mode 100644
index 0000000..24e56f1
--- /dev/null
+++ b/web/app/lib/metrics/metrics.ts
@@ -0,0 +1,147 @@
+// 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 { MetricDataMap } from "../interfaces/metrics/MetricDataMap";
+import { getLogger, LoggerNames } from "../logging/logger";
+
+export enum Metrics {
+ HTTP_REQUEST = "http_request",
+ API_REQUEST = "api_request",
+ DATABASE_OPERATION = "database_operation",
+ APPLICATION_ERROR = "application_error",
+ METRICS_GENERATION_TIME = "metrics_generation_time",
+ METRICS_QUEUE_LENGTH = "metrics_queue_length",
+ METRICS_QUEUE_LENGTH_BY_METRIC = "metrics_queue_length_by_metric"
+}
+
+interface MetricEntry {
+ timestamp: number;
+ id: number;
+ type: T;
+ data: MetricDataMap[T];
+}
+
+const logger = getLogger(LoggerNames.METRICS);
+
+export class MetricsManager {
+ private metrics = new Map>>();
+ private currentMetricID = 0;
+ constructor() {
+ // every 10 minutes, clear metrics older than 1 hour (3600000 ms)
+ setInterval(() => {
+ this.clearOldMetrics(60 * 60 * 1000);
+ }, 10 * 60 * 1000);
+ }
+
+ private clearOldMetrics(maxAgeMs: number) {
+ const cutoff = Date.now() - maxAgeMs;
+ for (const [metric, entries] of Array.from(this.metrics.entries())) {
+ const kept = entries.filter(entry => {
+ if (entry.timestamp < cutoff) {
+ logger.verbose(`Clearing old metric entry ID ${entry.id} of type ${entry.type}`);
+ return false;
+ }
+ return true;
+ });
+
+ if (kept.length === 0) {
+ this.metrics.delete(metric);
+ logger.verbose(`Deleted metric ${metric} as it had no recent entries.`);
+ } else {
+ this.metrics.set(metric, kept);
+ }
+ }
+ }
+
+ /**
+ * Submits a metric for tracking.
+ * Adds the metric entry to the queue, which will be sent next time metrics are flushed.
+ *
+ * @param metric The metric to submit.
+ * @param data The data associated with the metric.
+ */
+ public submitMetric(metric: T, data: MetricDataMap[T]) {
+ if (!this.metrics.has(metric)) {
+ this.metrics.set(metric, []);
+ }
+
+ const metricData = this.metrics.get(metric);
+ if (!metricData) {
+ throw new Error("Metric data is undefined for metric: " + metric);
+ }
+
+ const entry: MetricEntry = {
+ timestamp: Date.now(),
+ id: this.currentMetricID++,
+ type: metric,
+ data
+ } as MetricEntry;
+
+ metricData.push(entry);
+
+ logger.verbose(`Metric submitted: ${metric}`);
+ }
+
+ public getMetrics() {
+ return this.metrics;
+ }
+}
+
+export const metricsManager = new MetricsManager();
+
+// Only register process handlers in Node.js environment (not Edge Runtime)
+if (typeof process !== 'undefined' && process.on) {
+ process.on("unhandledRejection", (reason: unknown) => {
+ // Normalize to an Error-like object
+ const err = reason instanceof Error
+ ? reason
+ : new Error(
+ typeof reason === "string" ? reason
+ : reason === undefined ? "Unhandled rejection: undefined"
+ : JSON.stringify(reason)
+ );
+
+ // Prepare a safe serializable payload
+ const payload = {
+ name: err.name,
+ message: err.message,
+ stack: err.stack ? err.stack : undefined
+ };
+
+ // Submit metric (cast to any if MetricDataMap shape doesn't match)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ metricsManager.submitMetric(Metrics.APPLICATION_ERROR, payload as any);
+
+ logger.error("Unhandled rejection caught", err);
+ });
+
+ process.on("uncaughtException", (err: Error) => {
+ // Prepare a safe serializable payload
+ const payload = {
+ name: err.name,
+ message: err.message,
+ stack: err.stack ? err.stack : undefined
+ };
+
+ // Submit metric (cast to any if MetricDataMap shape doesn't match)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ metricsManager.submitMetric(Metrics.APPLICATION_ERROR, payload as any);
+
+ logger.error("Uncaught exception caught", err);
+ });
+}
diff --git a/web/app/lib/middleware/withMetricsAndLogging.ts b/web/app/lib/middleware/withMetricsAndLogging.ts
new file mode 100644
index 0000000..e539eaa
--- /dev/null
+++ b/web/app/lib/middleware/withMetricsAndLogging.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 { NextRequest, NextResponse } from "next/server";
+import { getLogger } from "../logging/logger";
+import { Metrics, metricsManager } from "../metrics/metrics";
+import type { MetricDataMap } from "../interfaces/metrics/MetricDataMap";
+
+/**
+ * Wrapper for API routes that adds logging and metrics tracking
+ */
+export function withMetricsAndLogging(
+ handler: (req: NextRequest) => Promise,
+ routeName: string
+) {
+ const logger = getLogger(`api/${routeName}`);
+
+ return async (req: NextRequest): Promise => {
+ const startTime = performance.now();
+ const method = req.method;
+ const pathname = new URL(req.url).pathname;
+
+ logger.debug(`${method} ${pathname}`);
+
+ try {
+ const response = await handler(req);
+ const durationMs = performance.now() - startTime;
+ const statusCode = response.status;
+
+ // Submit metrics
+ metricsManager.submitMetric(Metrics.API_REQUEST, {
+ endpoint: pathname,
+ method,
+ statusCode,
+ durationMs
+ });
+
+ logger.verbose(`${method} ${pathname} - ${statusCode} - ${durationMs.toFixed(2)}ms`);
+
+ return response;
+ } catch (error) {
+ const durationMs = performance.now() - startTime;
+ logger.error(`${method} ${pathname} failed after ${durationMs.toFixed(2)}ms:`, error);
+
+ // Submit error metric
+ const errorPayload: MetricDataMap[Metrics.APPLICATION_ERROR] = {
+ name: error instanceof Error ? error.name : "Unknown",
+ message: error instanceof Error ? error.message : String(error),
+ ...(error instanceof Error && error.stack ? { stack: error.stack } : {})
+ };
+ metricsManager.submitMetric(Metrics.APPLICATION_ERROR, errorPayload);
+
+ throw error;
+ }
+ };
+}
diff --git a/web/docs/METRICS_AND_LOGGING.md b/web/docs/METRICS_AND_LOGGING.md
new file mode 100644
index 0000000..e659127
--- /dev/null
+++ b/web/docs/METRICS_AND_LOGGING.md
@@ -0,0 +1,185 @@
+# Metrics and Logging in the Web Application
+
+This document explains how to use the Prometheus metrics and Winston logging systems in the AlphaGameBot web application.
+
+## Overview
+
+The web application now includes:
+
+1. **Winston Logging**: Structured logging with different log levels
+2. **Prometheus Metrics**: Performance and operational metrics exposed at `/metrics`
+3. **Automatic Metric Collection**: Middleware for tracking HTTP requests
+
+## Using the Logger
+
+### Import and Create a Logger
+
+```typescript
+import { getLogger } from '@/app/lib/logging/logger';
+
+const logger = getLogger('api/my-route');
+```
+
+### Log Levels
+
+```typescript
+logger.error('Critical error occurred', error);
+logger.warn('Warning message');
+logger.info('Information message');
+logger.verbose('Verbose details');
+logger.debug('Debug information');
+```
+
+### Best Practices
+
+- Use descriptive logger names following the pattern: `api/route-name` or `component/name`
+- Always use the logger instead of `console.log`
+- Include relevant context in log messages
+
+## Using Metrics
+
+### Submitting Metrics
+
+```typescript
+import { metricsManager, Metrics } from '@/app/lib/metrics/metrics';
+
+// Submit an API request metric
+metricsManager.submitMetric(Metrics.API_REQUEST, {
+ endpoint: '/api/users',
+ method: 'GET',
+ statusCode: 200,
+ durationMs: 42.5
+});
+
+// Submit a database operation metric
+metricsManager.submitMetric(Metrics.DATABASE_OPERATION, {
+ model: 'User',
+ operation: 'findMany',
+ durationMs: 15.2
+});
+```
+
+### Available Metrics
+
+- `HTTP_REQUEST`: Track HTTP requests (method, path, status, duration)
+- `API_REQUEST`: Track API endpoint requests
+- `DATABASE_OPERATION`: Track database query performance
+- `APPLICATION_ERROR`: Track application errors
+- `METRICS_GENERATION_TIME`: Time to generate metrics
+- `METRICS_QUEUE_LENGTH`: Number of metrics in queue
+
+### Using the Metrics Wrapper
+
+For API routes, use the `withMetricsAndLogging` wrapper to automatically track requests:
+
+```typescript
+import { withMetricsAndLogging } from '@/app/lib/middleware/withMetricsAndLogging';
+import { NextRequest, NextResponse } from 'next/server';
+
+async function handler(req: NextRequest) {
+ // Your API logic here
+ return NextResponse.json({ status: 'ok' });
+}
+
+export const GET = withMetricsAndLogging(handler, 'my-endpoint');
+export const runtime = 'nodejs'; // Important: Specify Node.js runtime for logging/metrics
+```
+
+This wrapper automatically:
+- Logs the request
+- Tracks request duration
+- Submits metrics
+- Handles errors
+
+## Accessing Metrics
+
+Prometheus metrics are available at:
+
+```
+GET /api/metrics
+```
+
+This endpoint returns metrics in Prometheus format, including:
+- Default Node.js metrics (heap size, GC stats, etc.)
+- Custom application metrics (HTTP requests, database operations, etc.)
+
+## Adding New Metrics
+
+See the full guide in the bot's documentation: `../bot/docs/ADDING_METRICS.md`
+
+The process is the same for the web app:
+
+1. Add to `Metrics` enum in `app/lib/metrics/metrics.ts`
+2. Add data shape to `app/lib/interfaces/metrics/MetricDataMap.ts`
+3. Add configuration to `app/lib/metrics/definitions/metricConfigurations.ts`
+
+## Edge Runtime Compatibility
+
+**Important**: The logging and metrics systems require Node.js APIs and cannot be used in Next.js Edge Runtime.
+
+For API routes that need logging/metrics:
+- Add `export const runtime = 'nodejs';` to force Node.js runtime
+- Use the `withMetricsAndLogging` wrapper
+
+For middleware (which runs in Edge Runtime):
+- Basic console logging is used instead
+- Full metrics tracking should be done in API routes
+
+## Database Logging
+
+The Prisma client is configured to forward warn and error logs to Winston:
+
+```typescript
+client.$on('warn', (e) => {
+ logger.warn(`Prisma: ${e.message}`);
+});
+```
+
+This ensures all database warnings and errors are captured in the logging system.
+
+## Example: Complete API Route with Logging and Metrics
+
+```typescript
+import { NextRequest, NextResponse } from 'next/server';
+import { withMetricsAndLogging } from '@/app/lib/middleware/withMetricsAndLogging';
+import { getLogger } from '@/app/lib/logging/logger';
+import client from '@/app/lib/database';
+
+const logger = getLogger('api/users');
+
+export const runtime = 'nodejs';
+
+async function handler(req: NextRequest) {
+ logger.debug('Fetching users from database');
+
+ try {
+ const users = await client.user.findMany({
+ take: 10
+ });
+
+ logger.info(`Retrieved ${users.length} users`);
+
+ return NextResponse.json({ users });
+ } catch (error) {
+ logger.error('Failed to fetch users:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch users' },
+ { status: 500 }
+ );
+ }
+}
+
+export const GET = withMetricsAndLogging(handler, 'users');
+```
+
+## Monitoring and Alerting
+
+The `/api/metrics` endpoint can be scraped by Prometheus for monitoring and alerting. Configure your Prometheus instance to scrape:
+
+```yaml
+scrape_configs:
+ - job_name: 'alphagamebot-web'
+ static_configs:
+ - targets: ['web:3000']
+ metrics_path: '/api/metrics'
+```
diff --git a/web/middleware.ts b/web/middleware.ts
new file mode 100644
index 0000000..92cc3e9
--- /dev/null
+++ b/web/middleware.ts
@@ -0,0 +1,53 @@
+// 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 { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+// Note: This middleware uses basic console logging as it runs in the Edge Runtime
+// which doesn't support full Node.js APIs like Winston. Metrics are recorded
+// in the API routes themselves which run in Node.js runtime.
+
+export function middleware(request: NextRequest) {
+ const { pathname, search } = request.nextUrl;
+ const method = request.method;
+ const fullPath = pathname + search;
+
+ // Basic console logging for middleware (Edge Runtime compatible)
+ if (process.env.NODE_ENV !== "production") {
+ // eslint-disable-next-line no-console
+ console.log(`[middleware] ${method} ${fullPath}`);
+ }
+
+ // Continue with the request
+ return NextResponse.next();
+}
+
+// Configure which paths the middleware should run on
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - public folder files
+ */
+ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
+ ],
+};
diff --git a/web/package.json b/web/package.json
index 981d761..b2011bd 100644
--- a/web/package.json
+++ b/web/package.json
@@ -24,12 +24,15 @@
"marked": "^4.3.0",
"next": "^16.0.7",
"prisma": "^6.19.0",
+ "prom-client": "^15.1.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
- "remark-gfm": "^4.0.1"
+ "remark-gfm": "^4.0.1",
+ "winston": "^3.19.0",
+ "winston-transport": "^4.9.0"
},
"devDependencies": {
"@prisma/config": "^6.19.0",