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 Vapor
import SwiftSMTP
import Clairvoyant
typealias PasswordHash = String
typealias SessionToken = String
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 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 {
/// A mapping between player name and generated access tokens for a session
@ -27,19 +49,16 @@ final class SQLiteDatabase {
private let mail: MailConfig?
init(db: Database, mail: Configuration.EMail?) {
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)
private let registeredPlayerCountMetric: Metric<Int>
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 {
@ -62,9 +81,20 @@ final class SQLiteDatabase {
let token = SessionToken.newToken()
self.sessionTokenForPlayer[name] = token
self.playerNameForToken[token] = name
await updateRegisteredPlayerCount(from: database)
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.
@ -96,8 +126,8 @@ final class SQLiteDatabase {
return
}
let recipient = Mail.User(name: name, email: email)
let mailConfiguration = mailData.mailConfiguration
let url = "\(mailConfiguration.serverDomain)/recovery.html?token=\(token)"
//let mailConfiguration = mailData.mailConfiguration
let url = "\(mailData.serverDomain)/recovery.html?token=\(token)"
let mail = Mail(
from: mailData.mailSender,
to: [recipient],
@ -106,12 +136,12 @@ final class SQLiteDatabase {
"""
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:
\(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,
@ -169,6 +199,7 @@ final class SQLiteDatabase {
let user = try await user(named: name, in: database)
try await tables.leaveTable(player: user, in: database)
try await User.query(on: database).filter(\.$name == name).delete()
await updateRegisteredPlayerCount(from: database)
}
func isValid(sessionToken token: SessionToken) -> Bool {

View File

@ -2,6 +2,7 @@ import Foundation
import WebSocketKit
import Vapor
import Fluent
import Clairvoyant
let maximumPlayersPerTable = 4
@ -13,22 +14,60 @@ final class TableManagement {
/// All tables indexed by their id
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
- Throws: Errors when the file could not be read
*/
init(db: Database) {
Table.query(on: db).with(\.$players).all().whenSuccess { loadedTables in
for table in loadedTables {
init(database: Database) {
self.tableCountMetric = .init("schafkopf.tables")
self.playingPlayerCountMetric = .init("schafkopf.playing")
self.connectedPlayerCountMetric = .init("schafkopf.connected")
Task {
do {
try await loadTables(from: database)
} catch {
print("Failed to load tables: \(error)")
}
}
}
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: db)
continue
_ = 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("\(self.tables.count) tables loaded")
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 }
}
/**
@ -45,6 +84,8 @@ final class TableManagement {
try await player.update(on: database)
let waitingTable = WaitingTable(newTable: table, user: player)
self.tables[waitingTable.id] = waitingTable
logTableCount()
logPlayingPlayerCount()
return waitingTable.tableInfo(forPlayer: player.name)
}
@ -77,6 +118,7 @@ final class TableManagement {
player.$table.id = table.id
try await player.update(on: database)
table.sendUpdateToAllPlayers()
logPlayingPlayerCount()
return table.tableInfo(forPlayer: player.name)
}
@ -108,6 +150,7 @@ final class TableManagement {
player.$table.id = nil
guard let table = WaitingTable(oldTable: oldTable, removing: player.name) else {
tables[oldTable.id] = nil
logTableCount()
try await player.update(on: database)
try await Table.query(on: database).filter(\.$id == oldTable.id).delete()
return
@ -116,6 +159,8 @@ final class TableManagement {
tables[table.id] = table
#warning("Update points for all players, add penalty if running game")
table.sendUpdateToAllPlayers()
logPlayingPlayerCount()
logConnectedPlayerCount()
try await player.update(on: database)
}
@ -123,6 +168,7 @@ final class TableManagement {
guard let table = currentTable(for: player) else {
return false
}
defer { logConnectedPlayerCount() }
return table.connect(player: player, using: socket)
}
@ -130,6 +176,7 @@ final class TableManagement {
guard let table = currentTable(for: player) else {
return
}
defer { logConnectedPlayerCount() }
table.disconnect(player: player)
}
@ -199,5 +246,6 @@ final class TableManagement {
func disconnectAllSockets() {
tables.values.forEach { $0.disconnectAllPlayers() }
logConnectedPlayerCount()
}
}

View File

@ -34,3 +34,14 @@ protocol ManageableTable {
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)
.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)
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))
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
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) {