Initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
2960
Cargo.lock
generated
Normal file
2960
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
Cargo.toml
Normal file
2
Cargo.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["snitch", "snitchlib"]
|
||||||
17
snitch/Cargo.toml
Normal file
17
snitch/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "snitch"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
snitchlib = { path = "../snitchlib" }
|
||||||
|
|
||||||
|
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"] }
|
||||||
3
snitch/build.rs
Normal file
3
snitch/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=migrations");
|
||||||
|
}
|
||||||
1
snitch/migrations/2022-11-11T22:56.down.sql
Normal file
1
snitch/migrations/2022-11-11T22:56.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE record;
|
||||||
10
snitch/migrations/2022-11-11T22:56.up.sql
Normal file
10
snitch/migrations/2022-11-11T22:56.up.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
);
|
||||||
49
snitch/src/api.rs
Normal file
49
snitch/src/api.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
62
snitch/src/dashboard.rs
Normal file
62
snitch/src/dashboard.rs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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}");
|
||||||
|
Status::InternalServerError
|
||||||
|
})?;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Record {
|
||||||
|
time: DateTime<Local>,
|
||||||
|
severity: String,
|
||||||
|
service: String,
|
||||||
|
message: String,
|
||||||
|
location: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages: Vec<_> = messages
|
||||||
|
.into_iter()
|
||||||
|
.map(|record| Record {
|
||||||
|
time: record.time.into(),
|
||||||
|
severity: record.severity,
|
||||||
|
service: record.service,
|
||||||
|
message: record.message,
|
||||||
|
location: record
|
||||||
|
.file_path
|
||||||
|
.zip(record.file_line)
|
||||||
|
.map(|(path, line)| format!("{path}:{line}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(RawHtml(Template::render(
|
||||||
|
"index",
|
||||||
|
context! {
|
||||||
|
messages: &messages
|
||||||
|
},
|
||||||
|
)))
|
||||||
|
}
|
||||||
12
snitch/src/database.rs
Normal file
12
snitch/src/database.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
34
snitch/src/main.rs
Normal file
34
snitch/src/main.rs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
#[macro_use]
|
||||||
|
extern crate rocket;
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod dashboard;
|
||||||
|
mod database;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use rocket_dyn_templates::Template;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct Opt {
|
||||||
|
/// PostgreSQL connect string
|
||||||
|
///
|
||||||
|
/// e.g. postgresql://user:pass@localhost:5432/database
|
||||||
|
#[clap(
|
||||||
|
long,
|
||||||
|
env = "DATABASE_URL",
|
||||||
|
default_value = "postgresql://postgres@localhost:5432/postgres"
|
||||||
|
)]
|
||||||
|
db: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[launch]
|
||||||
|
async fn rocket() -> _ {
|
||||||
|
let opt = Opt::parse();
|
||||||
|
|
||||||
|
let db = database::connect(&opt).await.expect("connect to database");
|
||||||
|
|
||||||
|
rocket::build()
|
||||||
|
.manage(db)
|
||||||
|
.mount("/", routes![dashboard::index, api::post_record])
|
||||||
|
.attach(Template::fairing())
|
||||||
|
}
|
||||||
51
snitch/templates/index.hbs
Normal file
51
snitch/templates/index.hbs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="">
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu|Ubuntu+Mono&display=swap">
|
||||||
|
|
||||||
|
<title>snitch</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
color: white;
|
||||||
|
background-color: #302f3b;
|
||||||
|
font-family: 'Ubuntu', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: wheat;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
border: 0.1em solid #5b3f63;
|
||||||
|
padding: 0.5em;
|
||||||
|
font-family: 'Ubuntu Mono', mono;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Service</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Location</th>
|
||||||
|
</tr>
|
||||||
|
{{#each messages}}
|
||||||
|
<tr>
|
||||||
|
<td>{{this.service}}</td>
|
||||||
|
<td>{{this.severity}}</td>
|
||||||
|
<td>{{this.time}}</td>
|
||||||
|
<td>{{this.message}}</td>
|
||||||
|
<td>{{this.location}}</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
snitchlib/Cargo.toml
Normal file
10
snitchlib/Cargo.toml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "snitchlib"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
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"] }
|
||||||
29
snitchlib/examples/log.rs
Normal file
29
snitchlib/examples/log.rs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||||
|
use snitchlib::SnitchLogger;
|
||||||
|
|
||||||
|
struct SimpleLogger;
|
||||||
|
|
||||||
|
impl log::Log for SimpleLogger {
|
||||||
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
|
metadata.level() <= Level::Info
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record) {
|
||||||
|
if self.enabled(record.metadata()) {
|
||||||
|
eprintln!("{} - {}", record.level(), record.args());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let logger = SnitchLogger::new(SimpleLogger, "http://localhost:8000", "snitchlib_example");
|
||||||
|
let logger = Box::leak(Box::new(logger));
|
||||||
|
log::set_logger(logger).expect("set logger");
|
||||||
|
log::set_max_level(LevelFilter::Info);
|
||||||
|
|
||||||
|
log::info!("Everything should work, let's try it!");
|
||||||
|
log::warn!("This is your last warning!");
|
||||||
|
log::error!("Error! Error!");
|
||||||
|
}
|
||||||
61
snitchlib/src/lib.rs
Normal file
61
snitchlib/src/lib.rs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
use log::{Level, Log, Metadata, Record};
|
||||||
|
use reqwest::blocking;
|
||||||
|
|
||||||
|
mod message;
|
||||||
|
pub use message::{LogMsg, Severity};
|
||||||
|
|
||||||
|
pub struct SnitchLogger<L: Log> {
|
||||||
|
wrapped: L,
|
||||||
|
url: String,
|
||||||
|
service_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<L: Log> SnitchLogger<L> {
|
||||||
|
pub fn new(wrapped: L, url: impl Into<String>, service_name: impl Into<String>) -> Self {
|
||||||
|
SnitchLogger {
|
||||||
|
wrapped,
|
||||||
|
url: url.into(),
|
||||||
|
service_name: service_name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<L: Log> Log for SnitchLogger<L> {
|
||||||
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
|
self.wrapped.enabled(metadata) || metadata.level() <= Level::Warn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record) {
|
||||||
|
eprintln!("log called");
|
||||||
|
self.wrapped.log(record);
|
||||||
|
|
||||||
|
let severity = match record.metadata().level() {
|
||||||
|
Level::Error => Severity::Error,
|
||||||
|
Level::Warn => Severity::Warning,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let record = LogMsg {
|
||||||
|
severity,
|
||||||
|
file: record.file().map(String::from),
|
||||||
|
line: record.line(),
|
||||||
|
..LogMsg::new(self.service_name.clone(), record.args().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = blocking::Client::new();
|
||||||
|
if let Err(e) = client.post(&self.url).json(&record).send() {
|
||||||
|
// TODO: log error (without sending it)
|
||||||
|
eprintln!("failed to send log record: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {
|
||||||
|
self.wrapped.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<L: Log> Drop for SnitchLogger<L> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
snitchlib/src/message.rs
Normal file
39
snitchlib/src/message.rs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user