manabrew-server 0.9.2

Matchmaking and lobby relay server for manabrew multiplayer
Documentation
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use tokio::sync::mpsc;
use tracing::warn;

use super::event::AnalyticsEvent;

const WARN_INTERVAL: Duration = Duration::from_secs(60);
const FILE_PREFIX: &str = "events-";
const FILE_EXTENSION: &str = "jsonl";
const DATE_FORMAT: &str = "%Y-%m-%d";

pub(super) fn spawn(rx: mpsc::Receiver<AnalyticsEvent>, dir: PathBuf) {
    std::thread::spawn(move || run(rx, dir));
}

fn run(mut rx: mpsc::Receiver<AnalyticsEvent>, dir: PathBuf) {
    if let Err(e) = std::fs::create_dir_all(&dir) {
        warn!("[analytics] cannot create events dir {:?}: {}", dir, e);
        return;
    }
    let mut current: Option<(String, File)> = None;
    let mut last_warn: Option<Instant> = None;
    while let Some(event) = rx.blocking_recv() {
        let Ok(line) = serde_json::to_string(&event) else {
            continue;
        };
        let date = today();
        if current.as_ref().map(|(d, _)| d != &date).unwrap_or(true) {
            current = open_daily_file(&dir, &date, &mut last_warn).map(|file| (date, file));
        }
        let Some((_, file)) = current.as_mut() else {
            continue;
        };
        if let Err(e) = writeln!(file, "{line}") {
            warn_rate_limited(
                &mut last_warn,
                format_args!("[analytics] write failed: {e}"),
            );
            current = None;
        }
    }
}

fn open_daily_file(dir: &Path, date: &str, last_warn: &mut Option<Instant>) -> Option<File> {
    let path = dir.join(format!("{FILE_PREFIX}{date}.{FILE_EXTENSION}"));
    match OpenOptions::new().create(true).append(true).open(&path) {
        Ok(file) => Some(file),
        Err(e) => {
            warn_rate_limited(
                last_warn,
                format_args!("[analytics] cannot open {path:?}: {e}"),
            );
            None
        }
    }
}

pub(super) fn today() -> String {
    chrono::Utc::now().format(DATE_FORMAT).to_string()
}

pub(super) fn warn_rate_limited(last_warn: &mut Option<Instant>, message: std::fmt::Arguments<'_>) {
    if last_warn
        .map(|at| at.elapsed() < WARN_INTERVAL)
        .unwrap_or(false)
    {
        return;
    }
    *last_warn = Some(Instant::now());
    warn!("{}", message);
}