Add more metrics

This commit is contained in:
Christoph Hagen 2023-02-01 16:44:07 +01:00
parent 0a5139fd39
commit d30222911d
5 changed files with 141 additions and 29 deletions

View File

@ -0,0 +1,12 @@
import Foundation
extension Sequence {
func count(where isIncluded: (Element) -> Bool) -> Int {
reduce(0) { isIncluded($1) ? $0 + 1 : $0 }
}
func sum(converting: (Element) -> Int) -> Int {
reduce(0) { $0 + converting($1) }
}
}

View File

@ -2,19 +2,41 @@ import Foundation
import Fluent import Fluent
import Vapor import Vapor
import SwiftSMTP import SwiftSMTP
import Clairvoyant
typealias PasswordHash = String typealias PasswordHash = String
typealias SessionToken = String typealias SessionToken = String
private struct MailConfig { private struct MailConfig {
let mailConfiguration: Configuration.EMail /// The url to the root of the server
let serverDomain: String
/// The number of minutes until a password reset token is no longer valid
let tokenExpiryDuration: Int
let smtp: SMTP let smtp: SMTP
let mailSender: Mail.User let mailSender: Mail.User
} }
private extension Configuration.EMail {
var mailConfig: MailConfig {
let smtp = SMTP(
hostname: emailHostname,
email: email,
password: password)
let mailSender = Mail.User(name: "Schafkopf Server", email: email)
return .init(
serverDomain: serverDomain,
tokenExpiryDuration: tokenExpiryDuration,
smtp: smtp,
mailSender: mailSender)
}
}
final class SQLiteDatabase { final class SQLiteDatabase {
/// A mapping between player name and generated access tokens for a session /// A mapping between player name and generated access tokens for a session
@ -27,19 +49,16 @@ final class SQLiteDatabase {
private let mail: MailConfig? private let mail: MailConfig?
init(db: Database, mail: Configuration.EMail?) { private let registeredPlayerCountMetric: Metric<Int>
self.tables = TableManagement(db: db)
guard let mail else {
self.mail = nil
return
}
let smtp = SMTP(
hostname: mail.emailHostname,
email: mail.email,
password: mail.password)
let mailSender = Mail.User(name: "Schafkopf Server", email: mail.email)
self.mail = .init(mailConfiguration: mail, smtp: smtp, mailSender: mailSender) init(database: Database, mail: Configuration.EMail?) {
self.registeredPlayerCountMetric = Metric("schafkopf.players")
self.tables = TableManagement(database: database)
self.mail = mail?.mailConfig
Task {
await updateRegisteredPlayerCount(from: database)
}
} }
func registerPlayer(named name: PlayerName, hash: PasswordHash, email: String?, in database: Database) async throws -> SessionToken { func registerPlayer(named name: PlayerName, hash: PasswordHash, email: String?, in database: Database) async throws -> SessionToken {
@ -62,9 +81,20 @@ final class SQLiteDatabase {
let token = SessionToken.newToken() let token = SessionToken.newToken()
self.sessionTokenForPlayer[name] = token self.sessionTokenForPlayer[name] = token
self.playerNameForToken[token] = name self.playerNameForToken[token] = name
await updateRegisteredPlayerCount(from: database)
return token return token
} }
func updateRegisteredPlayerCount(from database: Database) async {
do {
let count = try await User.query(on: database).count()
registeredPlayerCountMetric.update(count)
} catch {
print("Failed to update player count metric: \(error)")
}
}
/** /**
Send a password reset email. Send a password reset email.
@ -96,8 +126,8 @@ final class SQLiteDatabase {
return return
} }
let recipient = Mail.User(name: name, email: email) let recipient = Mail.User(name: name, email: email)
let mailConfiguration = mailData.mailConfiguration //let mailConfiguration = mailData.mailConfiguration
let url = "\(mailConfiguration.serverDomain)/recovery.html?token=\(token)" let url = "\(mailData.serverDomain)/recovery.html?token=\(token)"
let mail = Mail( let mail = Mail(
from: mailData.mailSender, from: mailData.mailSender,
to: [recipient], to: [recipient],
@ -106,12 +136,12 @@ final class SQLiteDatabase {
""" """
Hello \(name), Hello \(name),
a reset of your account password has been requested for the Schafkopf Server at \(mailConfiguration.serverDomain). a reset of your account password has been requested for the Schafkopf Server at \(mailData.serverDomain).
To choose a new password, click the following link: To choose a new password, click the following link:
\(url) \(url)
The link will expire in \(mailConfiguration.tokenExpiryDuration) minutes. If you didn't request the password reset, you don't have to do anything. The link will expire in \(mailData.tokenExpiryDuration) minutes. If you didn't request the password reset, you don't have to do anything.
Regards, Regards,
@ -169,6 +199,7 @@ final class SQLiteDatabase {
let user = try await user(named: name, in: database) let user = try await user(named: name, in: database)
try await tables.leaveTable(player: user, in: database) try await tables.leaveTable(player: user, in: database)
try await User.query(on: database).filter(\.$name == name).delete() try await User.query(on: database).filter(\.$name == name).delete()
await updateRegisteredPlayerCount(from: database)
} }
func isValid(sessionToken token: SessionToken) -> Bool { func isValid(sessionToken token: SessionToken) -> Bool {

View File

@ -2,6 +2,7 @@ import Foundation
import WebSocketKit import WebSocketKit
import Vapor import Vapor
import Fluent import Fluent
import Clairvoyant
let maximumPlayersPerTable = 4 let maximumPlayersPerTable = 4
@ -12,25 +13,63 @@ final class TableManagement {
/// All tables indexed by their id /// All tables indexed by their id
private var tables = [UUID : ManageableTable]() private var tables = [UUID : ManageableTable]()
/// The metric to log the current number of tables
private var tableCountMetric: Metric<Int>
/// The metric describing the number of players currently sitting at a table
private let playingPlayerCountMetric: Metric<Int>
/// The metric describing the number of players currently connected via a websocket
private let connectedPlayerCountMetric: Metric<Int>
/** /**
Load the tables from a file in the storage folder Load the tables from a file in the storage folder
- Throws: Errors when the file could not be read - Throws: Errors when the file could not be read
*/ */
init(db: Database) { init(database: Database) {
Table.query(on: db).with(\.$players).all().whenSuccess { loadedTables in self.tableCountMetric = .init("schafkopf.tables")
for table in loadedTables { self.playingPlayerCountMetric = .init("schafkopf.playing")
guard !table.players.isEmpty else { self.connectedPlayerCountMetric = .init("schafkopf.connected")
_ = table.delete(on: db)
continue Task {
} do {
let id = table.id! try await loadTables(from: database)
self.tables[id] = WaitingTable(id: id, name: table.name, isPublic: table.isPublic, players: table.players) } catch {
print("Failed to load tables: \(error)")
} }
print("\(self.tables.count) tables loaded")
} }
} }
private func loadTables(from database: Database) async throws {
try await Table.query(on: database).with(\.$players).all().forEach { table in
guard !table.players.isEmpty else {
_ = table.delete(on: database)
return
}
let id = table.id!
self.tables[id] = WaitingTable(id: id, name: table.name, isPublic: table.isPublic, players: table.players)
}
print("\(tables.count) tables loaded")
logTableCount()
logPlayingPlayerCount()
logConnectedPlayerCount()
}
private func logTableCount() {
tableCountMetric.update(tables.count)
}
private func logPlayingPlayerCount() {
let count = tables.values.sum { $0.playerCount }
}
private func logConnectedPlayerCount() {
let count = tables.values.sum { $0.numberOfConnectedPlayers }
}
/** /**
Create a new table with optional players. Create a new table with optional players.
- Parameter name: The name of the table - Parameter name: The name of the table
@ -45,6 +84,8 @@ final class TableManagement {
try await player.update(on: database) try await player.update(on: database)
let waitingTable = WaitingTable(newTable: table, user: player) let waitingTable = WaitingTable(newTable: table, user: player)
self.tables[waitingTable.id] = waitingTable self.tables[waitingTable.id] = waitingTable
logTableCount()
logPlayingPlayerCount()
return waitingTable.tableInfo(forPlayer: player.name) return waitingTable.tableInfo(forPlayer: player.name)
} }
@ -77,6 +118,7 @@ final class TableManagement {
player.$table.id = table.id player.$table.id = table.id
try await player.update(on: database) try await player.update(on: database)
table.sendUpdateToAllPlayers() table.sendUpdateToAllPlayers()
logPlayingPlayerCount()
return table.tableInfo(forPlayer: player.name) return table.tableInfo(forPlayer: player.name)
} }
@ -108,6 +150,7 @@ final class TableManagement {
player.$table.id = nil player.$table.id = nil
guard let table = WaitingTable(oldTable: oldTable, removing: player.name) else { guard let table = WaitingTable(oldTable: oldTable, removing: player.name) else {
tables[oldTable.id] = nil tables[oldTable.id] = nil
logTableCount()
try await player.update(on: database) try await player.update(on: database)
try await Table.query(on: database).filter(\.$id == oldTable.id).delete() try await Table.query(on: database).filter(\.$id == oldTable.id).delete()
return return
@ -116,6 +159,8 @@ final class TableManagement {
tables[table.id] = table tables[table.id] = table
#warning("Update points for all players, add penalty if running game") #warning("Update points for all players, add penalty if running game")
table.sendUpdateToAllPlayers() table.sendUpdateToAllPlayers()
logPlayingPlayerCount()
logConnectedPlayerCount()
try await player.update(on: database) try await player.update(on: database)
} }
@ -123,6 +168,7 @@ final class TableManagement {
guard let table = currentTable(for: player) else { guard let table = currentTable(for: player) else {
return false return false
} }
defer { logConnectedPlayerCount() }
return table.connect(player: player, using: socket) return table.connect(player: player, using: socket)
} }
@ -130,6 +176,7 @@ final class TableManagement {
guard let table = currentTable(for: player) else { guard let table = currentTable(for: player) else {
return return
} }
defer { logConnectedPlayerCount() }
table.disconnect(player: player) table.disconnect(player: player)
} }
@ -199,5 +246,6 @@ final class TableManagement {
func disconnectAllSockets() { func disconnectAllSockets() {
tables.values.forEach { $0.disconnectAllPlayers() } tables.values.forEach { $0.disconnectAllPlayers() }
logConnectedPlayerCount()
} }
} }

View File

@ -34,3 +34,14 @@ protocol ManageableTable {
func disconnectAllPlayers() func disconnectAllPlayers()
} }
extension ManageableTable {
var numberOfConnectedPlayers: Int {
allPlayers.count { $0.isConnected }
}
var playerCount: Int {
allPlayers.count
}
}

View File

@ -22,7 +22,17 @@ public func configure(_ app: Application) throws {
let configPath = URL(fileURLWithPath: app.directory.resourcesDirectory) let configPath = URL(fileURLWithPath: app.directory.resourcesDirectory)
.appendingPathComponent("config.json") .appendingPathComponent("config.json")
let configuration = try Configuration(loadFromUrl: configPath) let configuration: Configuration
do {
configuration = try Configuration(loadFromUrl: configPath)
} catch {
status.update(.initializationFailure)
monitor.log("Failed to read configuration: \(error)")
// Note: If configuration can't be loaded, then the server will run on the wrong port
// and access to metrics is impossible, since no tokens are loaded
return
}
configuration.monitoringTokens.map { $0.data(using: .utf8)! }.forEach(accessManager.add) configuration.monitoringTokens.map { $0.data(using: .utf8)! }.forEach(accessManager.add)
app.http.server.configuration.port = configuration.serverPort app.http.server.configuration.port = configuration.serverPort
@ -55,7 +65,7 @@ public func configure(_ app: Application) throws {
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
let db = app.databases.database(.sqlite, logger: .init(label: "Init"), on: app.databases.eventLoopGroup.next())! let db = app.databases.database(.sqlite, logger: .init(label: "Init"), on: app.databases.eventLoopGroup.next())!
server = SQLiteDatabase(db: db, mail: configuration.mail) server = SQLiteDatabase(database: db, mail: configuration.mail)
// Gracefully shut down by closing potentially open socket // Gracefully shut down by closing potentially open socket
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) {