use std::{collections::HashMap, mem};
use colored::Colorize;
use diesel::{prelude::*, upsert::excluded};
use crate::metrics::{RESULTS_PER_PAGE, migrations};
use super::MetricsError;
#[allow(clippy::wildcard_imports)]
use self::user_agents::dsl::*;
diesel::table! {
user_agents (agent) {
agent -> Text,
request_count -> BigInt,
poison_bytes_sent -> BigInt,
total_bytes_sent -> BigInt,
}
}
#[derive(Clone)]
pub struct UserAgent(String);
impl UserAgent {
pub fn new(user_agent: &str) -> Self {
let truncated_user_agent = user_agent
.chars()
.take(Metrics::MAX_USER_AGENT_CHAR_LENGTH)
.collect();
Self(truncated_user_agent)
}
fn inner(&self) -> &str {
self.0.as_ref()
}
}
pub struct Metrics {
counts: HashMap<String, MetricsEntry>,
unflushed_count: u32,
db_path: String,
}
#[derive(Selectable, Queryable)]
#[diesel(table_name = user_agents)]
#[cfg_attr(test, derive(Clone))]
pub struct MetricsEntry {
pub request_count: i64,
pub poison_bytes_sent: i64,
pub total_bytes_sent: i64,
}
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)?;
migrations::run_db_migrations(&mut conn)?;
Ok(Self {
db_path,
counts: HashMap::new(),
unflushed_count: 0,
})
}
pub fn count_request(&mut self, user_agent: &UserAgent) {
self.unflushed_count += 1;
self.entry(user_agent).request_count += 1;
self.flush_if_full();
}
pub fn record_poison_bytes(&mut self, user_agent: &UserAgent, bytes_sent: usize) {
self.entry(user_agent).poison_bytes_sent += i64::try_from(bytes_sent).unwrap_or(i64::MAX);
self.flush_if_full();
}
pub fn record_total_bytes(&mut self, user_agent: &UserAgent, bytes_sent: usize) {
self.entry(user_agent).total_bytes_sent += i64::try_from(bytes_sent).unwrap_or(i64::MAX);
self.flush_if_full();
}
fn entry(&mut self, user_agent: &UserAgent) -> &mut MetricsEntry {
if !self.counts.contains_key(user_agent.inner()) {
self.counts.insert(
user_agent.inner().to_owned(),
MetricsEntry {
request_count: 0,
poison_bytes_sent: 0,
total_bytes_sent: 0,
},
);
}
self.counts
.get_mut(user_agent.inner())
.expect("we just inserted this user agent if not present")
}
pub fn flush_if_full(&mut self) {
if self.unflushed_count < Metrics::MAX_UNFLUSHED_COUNT {
return;
}
self.unflushed_count = 0;
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 = 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, MetricsEntry)>, MetricsError> {
let offset = page.saturating_sub(1) * RESULTS_PER_PAGE;
let mut conn = SqliteConnection::establish(&self.db_path)?;
let entries = user_agents
.select((agent, MetricsEntry::as_select()))
.order_by(request_count.desc())
.limit(RESULTS_PER_PAGE as i64)
.offset(offset as i64)
.load(&mut conn)?;
Ok(entries)
}
}
fn flush_to_db(counts: HashMap<String, MetricsEntry>, 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, row)| {
(
agent.eq(ua),
request_count.eq(row.request_count),
poison_bytes_sent.eq(row.poison_bytes_sent),
total_bytes_sent.eq(row.total_bytes_sent),
)
})
.collect::<Vec<_>>();
if let Err(e) = diesel::insert_into(user_agents)
.values(rows)
.on_conflict(agent)
.do_update()
.set((
request_count.eq(request_count + excluded(request_count)),
poison_bytes_sent.eq(poison_bytes_sent + excluded(poison_bytes_sent)),
total_bytes_sent.eq(total_bytes_sent + excluded(total_bytes_sent)),
))
.execute(&mut conn)
{
eprintln!("{}: {e}", "Failed to write metrics to database".red());
}
}
impl Drop for Metrics {
fn drop(&mut self) {
self.flush_blocking();
}
}
#[cfg(test)]
mod test {
use crate::test_utils;
use super::*;
#[test]
fn recording_methods_track_per_agent_metrics() {
let mut expected_request_count = 0;
let mut expected_poison_bytes_sent = 0;
let mut expected_total_bytes_sent = 0;
macro_rules! assert_expected_entry {
($entry:expr) => {
assert_eq!($entry.request_count, expected_request_count);
assert_eq!($entry.poison_bytes_sent, expected_poison_bytes_sent);
assert_eq!($entry.total_bytes_sent, expected_total_bytes_sent);
};
}
let (_file, path) = test_utils::temp_file();
let mut metrics = Metrics::new(path).unwrap();
let user_agent = UserAgent::new("bot");
expected_request_count += 1;
metrics.count_request(&user_agent);
assert_expected_entry!(metrics.counts.get("bot").unwrap());
expected_poison_bytes_sent += 64;
metrics.record_poison_bytes(&user_agent, 64);
assert_expected_entry!(metrics.counts.get("bot").unwrap());
expected_total_bytes_sent += 1000;
metrics.record_total_bytes(&user_agent, 1000);
assert_expected_entry!(metrics.counts.get("bot").unwrap());
expected_poison_bytes_sent += 128;
metrics.record_poison_bytes(&user_agent, 128);
assert_expected_entry!(metrics.counts.get("bot").unwrap());
expected_request_count += 1;
metrics.count_request(&user_agent);
assert_expected_entry!(metrics.counts.get("bot").unwrap());
}
#[test]
fn data_persisted_on_flush() {
let (_file, db_path) = test_utils::temp_file();
let mut conn = SqliteConnection::establish(&db_path).expect("failed to connect to test db");
diesel::sql_query(
"CREATE TABLE user_agents (
agent TEXT PRIMARY KEY,
request_count INTEGER NOT NULL,
poison_bytes_sent INTEGER NOT NULL,
total_bytes_sent INTEGER NOT NULL
)",
)
.execute(&mut conn)
.expect("failed to create test table");
let expected = [
(
"miasma/0.1".to_owned(),
MetricsEntry {
request_count: 5,
poison_bytes_sent: 64,
total_bytes_sent: 128,
},
),
(
"claudebot".to_owned(),
MetricsEntry {
request_count: 10,
poison_bytes_sent: 10_000,
total_bytes_sent: 12_000,
},
),
(
"safari".to_owned(),
MetricsEntry {
request_count: 15,
poison_bytes_sent: 2048,
total_bytes_sent: 40_000,
},
),
];
flush_to_db(HashMap::from(expected.clone()), &db_path);
let mut conn =
SqliteConnection::establish(&db_path).expect("failed to connect to database");
let rows = user_agents
.select((agent, MetricsEntry::as_select()))
.load::<(String, MetricsEntry)>(&mut conn)
.expect("failed to query test db");
assert_eq!(rows.len(), expected.len());
for (expected_ua, expected_row) in expected {
let (actual_ua, actual_row) = 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_row.request_count, expected_row.request_count);
assert_eq!(actual_row.poison_bytes_sent, expected_row.poison_bytes_sent);
assert_eq!(actual_row.total_bytes_sent, expected_row.total_bytes_sent);
}
}
}