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
11 changes: 7 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion web/app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 || '/'));
Expand All @@ -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,
Expand All @@ -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 });
}
}
33 changes: 33 additions & 0 deletions web/app/api/metrics/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
}
11 changes: 10 additions & 1 deletion web/app/api/system-status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
21 changes: 20 additions & 1 deletion web/app/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,32 @@
// https://opensource.org/licenses/MIT

import { PrismaClient } from "@prisma/client";
import { getLogger } from "./logging/logger";

const logger = getLogger("database");

declare global {
interface Window {
_prismaClient?: PrismaClient;
}
}

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;
61 changes: 61 additions & 0 deletions web/app/lib/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;
}
54 changes: 54 additions & 0 deletions web/app/lib/interfaces/metrics/MetricDataMap.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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
}
}
102 changes: 102 additions & 0 deletions web/app/lib/logging/logger.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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