STUFUFUFUFF

This commit is contained in:
2024-09-21 14:08:24 +02:00
parent d180e72373
commit 7110a9e8f6
21 changed files with 1190 additions and 370 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
*.db

1051
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,8 @@
[workspace]
members = ["snitch", "snitchlib"]
members = [
"snitch-lib",
"snitch-web",
"snitch-cli",
]
resolver = "2"

16
snitch-cli/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "snitch"
version = "1.0.0"
edition = "2021"
[dependencies]
snitch-lib = { path = "../snitch-lib" }
log = "0.4"
serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.0.23", features = ["derive", "env"] }
eyre = "0.6.12"
color-eyre = "0.6.3"
reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls", "json", "blocking"] }
serde_json = "1.0.128"

54
snitch-cli/src/main.rs Normal file
View File

@ -0,0 +1,54 @@
use chrono::Local;
use clap::Parser;
use eyre::{eyre, WrapErr};
use snitch_lib::{probe_hostname, LogMsg, Severity};
#[derive(Parser)]
struct Opt {
/// URL of snitch
#[clap(long, env = "SNITCH_URL")]
url: String,
/// Name of this service
#[clap(long, env = "SNITCH_SERVICE")]
service: String,
/// Name of this service
#[clap(short, long, env = "SNITCH_SEVERITY", default_value = "Error")]
severity: Severity,
/// Name of this service
#[clap(env = "SNITCH_MESSAGE")]
message: String,
}
fn main() {
if let Err(e) = run() {
eprintln!("snitch error: {e:?}");
}
}
fn run() -> eyre::Result<()> {
let opt = Opt::parse();
color_eyre::install()?;
let message = LogMsg {
time: Some(Local::now()),
severity: opt.severity,
service: opt.service,
message: opt.message,
hostname: probe_hostname(),
file: None,
line: None,
};
let message = serde_json::to_string(&message).wrap_err("Failed to serialize log message")?;
let client = reqwest::blocking::Client::new();
client
.post(&opt.url)
.body(message)
.send()
.wrap_err(eyre!("Failed to upload log message to {:?}", opt.url))?;
Ok(())
}

View File

@ -1,5 +1,5 @@
[package]
name = "snitchlib"
name = "snitch-lib"
version = "1.0.0"
edition = "2021"
@ -8,3 +8,4 @@ log = "0.4"
serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.11.12", default-features = false, features = ["rustls-tls", "serde_json", "blocking", "json"] }
eyre = "0.6.12"

View File

@ -1,5 +1,5 @@
use log::{Level, LevelFilter, Log, Metadata, Record};
use snitchlib::SnitchLogger;
use log::{Level, LevelFilter, Metadata, Record};
use snitch_lib::SnitchLogger;
struct SimpleLogger;
@ -18,7 +18,7 @@ impl log::Log for SimpleLogger {
}
fn main() {
let logger = SnitchLogger::new(SimpleLogger, "http://localhost:8000", "snitchlib_example");
let logger = SnitchLogger::new(SimpleLogger, "http://localhost:8000", "snitch-lib-example");
let logger = Box::leak(Box::new(logger));
log::set_logger(logger).expect("set logger");
log::set_max_level(LevelFilter::Info);

View File

@ -1,3 +1,5 @@
use std::process::Command;
use log::{Level, Log, Metadata, Record};
use reqwest::blocking;
@ -8,6 +10,7 @@ pub struct SnitchLogger<L: Log> {
wrapped: L,
url: String,
service_name: String,
hostname: String,
}
impl<L: Log> SnitchLogger<L> {
@ -16,6 +19,7 @@ impl<L: Log> SnitchLogger<L> {
wrapped,
url: url.into(),
service_name: service_name.into(),
hostname: probe_hostname(),
}
}
}
@ -26,7 +30,6 @@ impl<L: Log> Log for SnitchLogger<L> {
}
fn log(&self, record: &Record) {
eprintln!("log called");
self.wrapped.log(record);
let severity = match record.metadata().level() {
@ -39,7 +42,11 @@ impl<L: Log> Log for SnitchLogger<L> {
severity,
file: record.file().map(String::from),
line: record.line(),
..LogMsg::new(self.service_name.clone(), record.args().to_string())
..LogMsg::new(
self.service_name.clone(),
record.args().to_string(),
self.hostname.clone(),
)
};
let client = blocking::Client::new();
@ -54,6 +61,16 @@ impl<L: Log> Log for SnitchLogger<L> {
}
}
pub fn probe_hostname() -> String {
let output = Command::new("uname").arg("-n").output().ok();
output
.as_ref()
.and_then(|output| std::str::from_utf8(&output.stdout).ok())
.unwrap_or("<unknown_hostname>")
.to_string()
}
impl<L: Log> Drop for SnitchLogger<L> {
fn drop(&mut self) {
self.flush();

78
snitch-lib/src/message.rs Normal file
View File

@ -0,0 +1,78 @@
use std::str::FromStr;
use chrono::{DateTime, Local};
use eyre::{eyre, ContextCompat};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Severity {
Fatal,
Error,
Warning,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LogMsg {
/// The time that the error was reported.
pub time: Option<DateTime<Local>>,
pub severity: Severity,
/// The service which reported the error.
pub service: String,
/// The log message.
pub message: String,
/// The host that the service is running on.
pub hostname: String,
pub file: Option<String>,
pub line: Option<u32>,
}
impl LogMsg {
pub fn new(service: String, message: String, hostname: String) -> Self {
Self {
time: Some(Local::now()),
severity: Severity::Error,
service,
message,
hostname,
file: None,
line: None,
}
}
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Severity::Fatal => "FATAL",
Severity::Error => "ERROR",
Severity::Warning => "WARNING",
}
}
}
impl FromStr for Severity {
type Err = eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let err = || eyre!("{s:?} is not a valid severity level");
let first_char = s
.trim()
.chars()
.next()
.wrap_err_with(err)?
.to_ascii_lowercase();
Ok(match first_char {
'f' => Severity::Fatal,
'e' => Severity::Error,
'w' => Severity::Warning,
_ => return Err(err()),
})
}
}

View File

@ -1,17 +1,19 @@
[package]
name = "snitch"
name = "snitch-web"
version = "1.0.0"
edition = "2021"
[dependencies]
snitchlib = { path = "../snitchlib" }
snitch-lib = { path = "../snitch-lib" }
anyhow = "*"
log = "0.4"
serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "postgres", "sqlite", "migrate", "macros", "chrono"] }
rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["handlebars"] }
rocket_db_pools = { version = "0.1.0-rc.2", features = ["sqlx_postgres"] }
rocket = { version = "0.5.0-rc.2", features = ["json"] }
clap = { version = "4.0.23", features = ["derive", "env"] }
eyre = "0.6.12"
color-eyre = "0.6.3"
redb = { version = "2.1.3", features = ["logging"] }
rmp-serde = "1.3.0"

19
snitch-web/src/api.rs Normal file
View File

@ -0,0 +1,19 @@
use chrono::Local;
use rocket::{http::Status, serde::json::Json, State};
use snitch_lib::LogMsg;
use crate::database::Database;
#[post("/", data = "<record>")]
pub async fn post_record(db: &State<Database>, record: Json<LogMsg>) -> Status {
let mut record = record.into_inner();
record.time.get_or_insert_with(Local::now);
let result = db.write_log(&record);
if let Err(e) = result {
log::error!("failed to insert record: {e:?}");
return Status::InternalServerError;
}
Status::Ok
}

View File

@ -1,37 +1,22 @@
use chrono::{DateTime, Local};
use rocket::{http::Status, response::content::RawHtml, State};
use rocket_dyn_templates::{context, Template};
use serde::Serialize;
use sqlx::query;
use crate::database::Database;
#[get("/")]
pub async fn index(db: &State<Database>) -> Result<RawHtml<Template>, Status> {
let mut conn = db.inner().acquire().await.map_err(|e| {
log::error!("failed to get db connection: {e}");
Status::InternalServerError
})?;
// TODO:
// maybe only show most recent error for each service in dashboard view, i.e.:
// select distinct on (service) time, message from (select * from record order by time desc) as foo;
let messages = query!(
"
SELECT time, service, severity, message, file_path, file_line FROM record
ORDER BY time DESC;
"
)
.fetch_all(&mut conn)
.await
.map_err(|e| {
log::error!("failed to query record: {e}");
let messages = db.get_all_log_messages().map_err(|e| {
log::error!("failed to query database: {e}");
Status::InternalServerError
})?;
#[derive(Serialize)]
struct Record {
time: DateTime<Local>,
time: String,
severity: String,
service: String,
message: String,
@ -40,14 +25,14 @@ SELECT time, service, severity, message, file_path, file_line FROM record
let messages: Vec<_> = messages
.into_iter()
.map(|record| Record {
time: record.time.into(),
severity: record.severity,
.map(|(_id, record)| Record {
time: record.time.map(|time| time.to_string()).unwrap_or_default(),
severity: record.severity.as_str().to_string(),
service: record.service,
message: record.message,
location: record
.file_path
.zip(record.file_line)
.file
.zip(record.line)
.map(|(path, line)| format!("{path}:{line}"))
.unwrap_or_default(),
})

136
snitch-web/src/database.rs Normal file
View File

@ -0,0 +1,136 @@
use std::{
fmt::Debug,
marker::PhantomData,
sync::atomic::{AtomicU64, Ordering},
};
use eyre::{eyre, WrapErr};
use redb::ReadableTable;
use serde::{de::DeserializeOwned, Serialize};
use snitch_lib::LogMsg;
use crate::Opt;
pub struct Database {
inner: redb::Database,
next_log_id: AtomicU64,
}
#[derive(Debug)]
struct Serialized<T> {
packed: Vec<u8>,
_type: PhantomData<T>,
}
pub(crate) fn open(opt: &Opt) -> eyre::Result<Database> {
let inner = redb::Database::create(&opt.db)
.context(eyre!("Failed to open database at {}", opt.db.display()))?;
let txn = inner.begin_write()?;
let next_log_id = txn
.open_table(table::LOG_MESSAGE)?
.range::<u64>(..)?
.next_back()
.transpose()
.ok()
.flatten()
.map(|(id, _)| id.value() + 1)
.unwrap_or_default();
txn.commit()?;
Ok(Database {
inner,
next_log_id: next_log_id.into(),
})
}
mod table {
use super::Serialized;
use redb::TableDefinition;
use snitch_lib::LogMsg;
pub const LOG_MESSAGE: TableDefinition<u64, Serialized<LogMsg>> =
TableDefinition::new("log_message");
}
impl Database {
pub fn write_log(&self, log: &LogMsg) -> eyre::Result<u64> {
let txn = self.inner.begin_write()?;
let id = self.next_log_id.fetch_add(1, Ordering::SeqCst);
txn.open_table(table::LOG_MESSAGE)?
.insert(id, Serialized::serialize(log))?;
txn.commit()?;
Ok(id)
}
pub fn get_all_log_messages(&self) -> eyre::Result<Vec<(u64, LogMsg)>> {
let txn = self.inner.begin_read()?;
txn.open_table(table::LOG_MESSAGE)?
.range::<u64>(..)?
.map(|result| {
let (id, msg) = result?;
let id = id.value();
let msg = msg.value();
msg.deserialize().map(|msg| (id, msg))
})
.collect()
}
}
impl<T> Serialized<T>
where
T: Debug + Serialize + DeserializeOwned,
{
pub fn serialize(value: &T) -> Self {
let packed = rmp_serde::to_vec(&value).unwrap();
Self {
packed,
_type: PhantomData,
}
}
pub fn deserialize(&self) -> eyre::Result<T> {
rmp_serde::from_slice(&self.packed).wrap_err("Failed to deserialize")
}
}
impl<T> redb::Value for Serialized<T>
where
T: Debug + Serialize + DeserializeOwned,
{
type SelfType<'a> = Self
where
Self: 'a;
type AsBytes<'a> = &'a [u8]
where
Self: 'a;
fn fixed_width() -> Option<usize> {
None
}
fn from_bytes<'a>(data: &'a [u8]) -> Self::SelfType<'a>
where
Self: 'a,
{
Serialized {
packed: data.to_vec(),
_type: PhantomData,
}
}
fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a>
where
Self: 'a,
Self: 'b,
{
&value.packed
}
fn type_name() -> redb::TypeName {
redb::TypeName::new(std::any::type_name::<Serialized<T>>())
}
}

View File

@ -5,27 +5,26 @@ mod api;
mod dashboard;
mod database;
use std::path::PathBuf;
use clap::Parser;
use rocket_dyn_templates::Template;
#[derive(Parser)]
struct Opt {
/// PostgreSQL connect string
/// Filepath where snitch will store its database.
///
/// e.g. postgresql://user:pass@localhost:5432/database
#[clap(
long,
env = "DATABASE_URL",
default_value = "postgresql://postgres@localhost:5432/postgres"
)]
db: String,
#[clap(long, env = "SNITCH_DB", default_value = "./snitch.db")]
db: PathBuf,
}
#[launch]
async fn rocket() -> _ {
let opt = Opt::parse();
color_eyre::install().unwrap();
let db = database::connect(&opt).await.expect("connect to database");
let db = database::open(&opt).expect("open database");
rocket::build()
.manage(db)

View File

@ -1 +0,0 @@
DROP TABLE record;

View File

@ -1,10 +0,0 @@
CREATE TABLE record (
id SERIAL PRIMARY KEY,
time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
service VARCHAR(255) NOT NULL,
severity VARCHAR(16) NOT NULL,
message TEXT NOT NULL,
file_path TEXT,
file_line INTEGER
);

View File

@ -1,49 +0,0 @@
use rocket::{http::Status, serde::json::Json, State};
use snitchlib::LogMsg;
use sqlx::query;
use crate::database::Database;
#[post("/", data = "<record>")]
pub async fn post_record(db: &State<Database>, record: Json<LogMsg>) -> Status {
let mut conn = match db.inner().acquire().await {
Ok(conn) => conn,
Err(e) => {
log::error!("failed to get db connection: {e}");
return Status::InternalServerError;
}
};
let LogMsg {
time: _,
severity,
service,
message,
file,
line,
} = record.into_inner();
let result = query!(
"
INSERT INTO record(service,
severity,
message,
file_path,
file_line)
VALUES ($1, $2, $3, $4, $5);
",
service,
format!("{:?}", severity),
message,
file,
line.map(|line| line as i32),
)
.execute(&mut conn)
.await;
if let Err(e) = result {
log::error!("failed to insert record: {e:?}");
}
Status::Ok
}

View File

@ -1,12 +0,0 @@
use crate::Opt;
use sqlx::PgPool;
pub type Database = PgPool;
pub(crate) async fn connect(opt: &Opt) -> anyhow::Result<Database> {
let pool = PgPool::connect(&opt.db).await?;
sqlx::migrate!().run(&pool).await?;
Ok(pool)
}

View File

@ -1,39 +0,0 @@
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Severity {
Fatal,
Error,
Warning,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct LogMsg {
/// The time that the error was reported.
pub time: DateTime<Local>,
pub severity: Severity,
/// The service which reported the error.
pub service: String,
/// The log message.
pub message: String,
pub file: Option<String>,
pub line: Option<u32>,
}
impl LogMsg {
pub fn new(service: String, message: String) -> Self {
Self {
time: Local::now(),
severity: Severity::Error,
service,
message,
file: None,
line: None,
}
}
}