use std::{collections::HashMap, mem};
use colored::Colorize;
use diesel::{prelude::*, upsert::excluded};
use crate::metrics::RESULTS_PER_PAGE;
use super::MetricsError;
use self::user_agents::dsl::*;
diesel::table! {
user_agents (agent) {
agent -> Text,
count -> BigInt,
}
}
pub struct Metrics {
counts: HashMap<String, usize>,
unflushed_count: u32,
db_path: String,
}
impl Metrics {
const MAX_UNFLUSHED_COUNT: u32 = 1_000;
const MAX_USER_AGENT_CHAR_LENGTH: usize = 1024;
pub fn new(db_path: String) -> Result<Self, MetricsError> {
let mut conn = SqliteConnection::establish(&db_path)?;
diesel::sql_query(
"CREATE TABLE IF NOT EXISTS user_agents (
agent TEXT PRIMARY KEY,
count INTEGER NOT NULL
)",
)
.execute(&mut conn)?;
Ok(Self {
db_path,
counts: HashMap::new(),
unflushed_count: 0,
})
}
pub fn count_request(&mut self, user_agent: &str) {
let truncated_user_agent = user_agent
.chars()
.take(Metrics::MAX_USER_AGENT_CHAR_LENGTH)
.collect();
self.unflushed_count += 1;
if let Some(c) = self.counts.get_mut(&truncated_user_agent) {
*c += 1;
} else {
self.counts.insert(truncated_user_agent, 1);
}
if self.unflushed_count >= Metrics::MAX_UNFLUSHED_COUNT {
self.unflushed_count = 0;
self.flush_in_background();
}
}
pub fn flush_in_background(&mut self) {
let flushing = mem::take(&mut self.counts);
let db_path = self.db_path.clone();
tokio::task::spawn_blocking(move || {
flush_to_db(flushing, &db_path);
});
}
pub fn flush_blocking(&mut self) {
let flushing = std::mem::take(&mut self.counts);
flush_to_db(flushing, &self.db_path);
}
pub fn list_useragents_by_count(
&mut self,
page: u32,
) -> Result<Vec<(String, i64)>, MetricsError> {
let offset = page.saturating_sub(1) * RESULTS_PER_PAGE as u32;
let mut conn = SqliteConnection::establish(&self.db_path)?;
let entries = user_agents
.order_by(count.desc())
.limit(RESULTS_PER_PAGE as i64)
.offset(offset as i64)
.load::<(String, i64)>(&mut conn)?;
Ok(entries)
}
}
fn flush_to_db(counts: HashMap<String, usize>, db_path: &str) {
let mut conn = match SqliteConnection::establish(db_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}: {e}", "Failed to connect to metrics database".red());
return;
}
};
#[allow(unused_must_use)]
diesel::sql_query("PRAGMA busy_timeout = 5000").execute(&mut conn);
let rows = counts
.into_iter()
.map(|(ua, c)| (agent.eq(ua), count.eq(c as i64)))
.collect::<Vec<_>>();
if let Err(e) = diesel::insert_into(user_agents)
.values(rows)
.on_conflict(agent)
.do_update()
.set(count.eq(count + excluded(count)))
.execute(&mut conn)
{
eprintln!(
"{}: {e}",
"Failed to write User-Agent counts to database".red()
);
}
}
impl Drop for Metrics {
fn drop(&mut self) {
self.flush_blocking();
}
}
#[cfg(test)]
mod test {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn counts_persisted_on_flush() {
let db_file = NamedTempFile::new()
.unwrap()
.path()
.to_str()
.unwrap()
.to_owned();
let mut conn = SqliteConnection::establish(&db_file).expect("failed to connect to test db");
diesel::sql_query(
"CREATE TABLE user_agents (
agent TEXT PRIMARY KEY,
count INTEGER NOT NULL
)",
)
.execute(&mut conn)
.expect("failed to create test table");
let expected = [
("miasma/0.1".to_owned(), 5),
("claudebot".to_owned(), 10),
("safari".to_owned(), 15),
];
flush_to_db(HashMap::from(expected.clone()), &db_file);
let mut conn =
SqliteConnection::establish(&db_file).expect("failed to connect to database");
let rows = user_agents
.load::<(String, i64)>(&mut conn)
.expect("failed to query test db");
assert_eq!(rows.len(), expected.len());
for (expected_ua, expected_count) in expected {
let (actual_ua, actual_count) = rows
.iter()
.find(|(ua, _)| ua.as_str() == expected_ua)
.expect("expected row not found in test db");
assert_eq!(actual_ua, &expected_ua);
assert_eq!(*actual_count, expected_count as i64);
}
}
}