Compare commits
1 Commits
master
...
refactor-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
506ce117d3
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1 @@
|
||||
/target
|
||||
*.db
|
||||
|
||||
2679
Cargo.lock
generated
2679
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,2 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"snitch-lib",
|
||||
"snitch-web",
|
||||
"snitch-cli",
|
||||
]
|
||||
|
||||
resolver = "2"
|
||||
members = ["snitch", "snitch_srv"]
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
[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"
|
||||
@ -1,100 +0,0 @@
|
||||
use std::io::{stdin, BufRead};
|
||||
|
||||
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,
|
||||
|
||||
/// Treat each line of the message as its own log message.
|
||||
#[clap(short, long, env = "SNITCH_LINES")]
|
||||
lines: bool,
|
||||
|
||||
/// Log message to snitch. If this is omitted, the message will be read from stdin.
|
||||
#[clap(env = "SNITCH_MESSAGE")]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("snitch error: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> eyre::Result<()> {
|
||||
let opt = Opt::parse();
|
||||
color_eyre::install()?;
|
||||
|
||||
if let Some(message) = &opt.message {
|
||||
log_from(&opt, message.as_bytes())
|
||||
} else {
|
||||
let stdin = stdin();
|
||||
log_from(&opt, stdin.lock())
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume a reader and log everything to snitch
|
||||
fn log_from(opt: &Opt, r: impl BufRead) -> eyre::Result<()> {
|
||||
if opt.lines {
|
||||
log_lines(opt, r)
|
||||
} else {
|
||||
log_reader(opt, r)
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume a reader and log everything as a single message to snitch
|
||||
fn log_reader(opt: &Opt, mut r: impl BufRead) -> eyre::Result<()> {
|
||||
let mut s = String::new();
|
||||
r.read_to_string(&mut s)?;
|
||||
log_message(opt, &s)
|
||||
}
|
||||
|
||||
/// Log each line from a reader to snitch
|
||||
fn log_lines(opt: &Opt, r: impl BufRead) -> eyre::Result<()> {
|
||||
for line in r.lines() {
|
||||
let line = line.wrap_err("Failed to read from stdin")?;
|
||||
let line = line.trim();
|
||||
if !line.is_empty() {
|
||||
log_message(opt, line)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log a single message to snitch
|
||||
fn log_message(opt: &Opt, message: &str) -> eyre::Result<()> {
|
||||
let message = LogMsg {
|
||||
time: Some(Local::now()),
|
||||
severity: opt.severity,
|
||||
service: opt.service.clone(),
|
||||
message: message.to_string(),
|
||||
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(())
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use eyre::{eyre, ContextCompat};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Severity {
|
||||
Warning,
|
||||
Error,
|
||||
Fatal,
|
||||
}
|
||||
|
||||
#[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: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
hostname: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
time: Some(Local::now()),
|
||||
severity: Severity::Error,
|
||||
service: service.into(),
|
||||
message: message.into(),
|
||||
hostname: hostname.into(),
|
||||
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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
[Unit]
|
||||
Description=snitch: Simple error logging web service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=SNITCH_DB=/var/lib/snitch/snitch.db
|
||||
Environment=ROCKET_TEMPLATE_DIR=/var/lib/snitch/templates
|
||||
ExecStart=snitch
|
||||
|
||||
Restart=always
|
||||
RestartSec=100ms
|
||||
RestartMaxDelaySec=10s
|
||||
RestartSteps=10
|
||||
@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
use std::{cmp::max, collections::BTreeMap};
|
||||
|
||||
use rocket::{http::Status, response::content::RawHtml, State};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use serde::Serialize;
|
||||
use snitch_lib::Severity;
|
||||
|
||||
use crate::database::Database;
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(db: &State<Database>) -> Result<RawHtml<Template>, Status> {
|
||||
db.drop_old_messages().map_err(|e| {
|
||||
log::error!("failed to query database: {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 records = db.get_all_log_messages().map_err(|e| {
|
||||
log::error!("failed to query database: {e}");
|
||||
Status::InternalServerError
|
||||
})?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ServiceRecords<'a> {
|
||||
first_record: &'a Record,
|
||||
last_record: &'a Record,
|
||||
severity: Severity,
|
||||
all_records: Vec<&'a Record>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Record {
|
||||
time: String,
|
||||
severity: Severity,
|
||||
service: String,
|
||||
message: String,
|
||||
location: String,
|
||||
}
|
||||
|
||||
let records: Vec<_> = records
|
||||
.into_iter()
|
||||
.map(|(_id, record)| Record {
|
||||
time: record.time.map(|time| time.to_string()).unwrap_or_default(),
|
||||
severity: record.severity,
|
||||
service: record.service,
|
||||
message: record.message,
|
||||
location: record
|
||||
.file
|
||||
.zip(record.line)
|
||||
.map(|(path, line)| format!("{path}:{line}"))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut records_by_service = BTreeMap::new();
|
||||
|
||||
for record in &records {
|
||||
let entry = records_by_service
|
||||
.entry(record.service.as_str())
|
||||
.or_insert_with(|| ServiceRecords {
|
||||
first_record: record,
|
||||
last_record: record,
|
||||
all_records: vec![],
|
||||
severity: record.severity,
|
||||
});
|
||||
|
||||
entry.last_record = record;
|
||||
entry.all_records.push(record);
|
||||
entry.severity = max(record.severity, entry.severity);
|
||||
}
|
||||
|
||||
Ok(RawHtml(Template::render(
|
||||
"index",
|
||||
context! {
|
||||
records: &records,
|
||||
records_by_service: &records_by_service,
|
||||
},
|
||||
)))
|
||||
}
|
||||
@ -1,238 +0,0 @@
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
marker::PhantomData,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
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>,
|
||||
}
|
||||
|
||||
mod table {
|
||||
use super::Serialized;
|
||||
use redb::TableDefinition;
|
||||
use snitch_lib::LogMsg;
|
||||
|
||||
pub const LOG_MESSAGE: TableDefinition<u64, Serialized<LogMsg>> =
|
||||
TableDefinition::new("log_message");
|
||||
}
|
||||
|
||||
pub const MAX_MESSAGE_AGE: Duration = Duration::weeks(8);
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn open(opt: &Opt) -> eyre::Result<Self> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Drop any messages older than [MAX_MESSAGE_AGE]
|
||||
pub fn drop_old_messages(&self) -> eyre::Result<usize> {
|
||||
static LAST_RUN: Mutex<DateTime<Utc>> = Mutex::new(DateTime::<Utc>::MIN_UTC);
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
// Only run this once a day
|
||||
{
|
||||
let mut last_run = LAST_RUN.lock().unwrap();
|
||||
if now > *last_run + Duration::days(1) {
|
||||
*last_run = now;
|
||||
} else {
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("cleaning up old messages");
|
||||
|
||||
let txn = self.inner.begin_write()?;
|
||||
let mut n = 0;
|
||||
txn.open_table(table::LOG_MESSAGE)?
|
||||
.retain_in(0.., |_id, log_msg| {
|
||||
let keep = match log_msg.deserialize() {
|
||||
Ok(log_msg) => match log_msg.time {
|
||||
Some(time) => {
|
||||
let too_old = now > time + MAX_MESSAGE_AGE;
|
||||
if too_old {
|
||||
log::info!("removing old message posted at {time}");
|
||||
}
|
||||
!too_old
|
||||
}
|
||||
|
||||
// Timestamp should always be set.
|
||||
// TODO: make it not be an option
|
||||
None => {
|
||||
log::warn!("removing message with no timestamp");
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
log::warn!("removing message that failed to deserialize");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !keep {
|
||||
n += 1;
|
||||
}
|
||||
|
||||
keep
|
||||
})?;
|
||||
|
||||
txn.commit()?;
|
||||
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
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>>())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Local;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_old_messages() {
|
||||
let tmp: u32 = rand::random();
|
||||
let tmp = format!("/tmp/{tmp:08x}.db");
|
||||
|
||||
let opt = Opt { db: tmp.into() };
|
||||
let db = Database::open(&opt).unwrap();
|
||||
|
||||
let now = Local::now();
|
||||
let msg_with_time = |time| LogMsg {
|
||||
time: Some(time),
|
||||
..LogMsg::new("service", "message", "hostname")
|
||||
};
|
||||
|
||||
// not old mesages
|
||||
db.write_log(&msg_with_time(now)).unwrap();
|
||||
db.write_log(&msg_with_time(now - MAX_MESSAGE_AGE + Duration::days(1)))
|
||||
.unwrap();
|
||||
|
||||
// old messages
|
||||
db.write_log(&msg_with_time(now - MAX_MESSAGE_AGE - Duration::days(1)))
|
||||
.unwrap();
|
||||
db.write_log(&msg_with_time(now - MAX_MESSAGE_AGE - Duration::days(365)))
|
||||
.unwrap();
|
||||
db.write_log(&msg_with_time(now - MAX_MESSAGE_AGE - Duration::seconds(1)))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.drop_old_messages().unwrap(), 3);
|
||||
}
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.service-error-list {
|
||||
max-height: 20em;
|
||||
font-size: small;
|
||||
overflow: scroll;
|
||||
transition: max-height ease-in-out 0.4s;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
max-height: 0;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function collapse(id) {
|
||||
document.getElementById(id).classList.toggle("collapsed")
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{{#if records}}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Severity</th>
|
||||
<th>Service</th>
|
||||
<th>Count</th>
|
||||
<th>Last message at</th>
|
||||
</tr>
|
||||
{{#each records_by_service}}
|
||||
<tr onclick="collapse('{{@key}}-error-list')">
|
||||
<td>{{this.severity}}</td>
|
||||
<td>{{@key}}</td>
|
||||
<td>{{len this.all_records}}</td>
|
||||
<td>{{this.last_record.time}}</td>
|
||||
</tr>
|
||||
<tr><td colspan="4">
|
||||
<div
|
||||
id="{{@key}}-error-list"
|
||||
class="service-error-list collapsed"
|
||||
>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Severity</th>
|
||||
<th>Message</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
{{#each this.all_records}}
|
||||
<tr>
|
||||
<td>{{this.severity}}</td>
|
||||
<td>{{this.message}}</td>
|
||||
<td>{{this.time}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
</div></td></tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
{{else}}
|
||||
<p>
|
||||
No entries! All is well.
|
||||
</p>
|
||||
{{/if}}
|
||||
</body>
|
||||
</html>
|
||||
@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "snitch-lib"
|
||||
name = "snitch"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
@ -8,4 +8,3 @@ 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"
|
||||
@ -1,5 +1,5 @@
|
||||
use log::{Level, LevelFilter, Metadata, Record};
|
||||
use snitch_lib::SnitchLogger;
|
||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||
use snitch::SnitchLogger;
|
||||
|
||||
struct SimpleLogger;
|
||||
|
||||
@ -18,7 +18,7 @@ impl log::Log for SimpleLogger {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let logger = SnitchLogger::new(SimpleLogger, "http://localhost:8000", "snitch-lib-example");
|
||||
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);
|
||||
@ -1,4 +1,4 @@
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{Level, Log, Metadata, Record};
|
||||
use reqwest::blocking;
|
||||
@ -10,7 +10,6 @@ pub struct SnitchLogger<L: Log> {
|
||||
wrapped: L,
|
||||
url: String,
|
||||
service_name: String,
|
||||
hostname: String,
|
||||
}
|
||||
|
||||
impl<L: Log> SnitchLogger<L> {
|
||||
@ -19,17 +18,20 @@ impl<L: Log> SnitchLogger<L> {
|
||||
wrapped,
|
||||
url: url.into(),
|
||||
service_name: service_name.into(),
|
||||
hostname: probe_hostname(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<L: Log> Log for SnitchLogger<L> {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
self.wrapped.enabled(metadata) || metadata.level() <= Level::Warn
|
||||
dbg!(self.wrapped.enabled(metadata) || metadata.level() <= Level::Warn)
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
eprintln!("log called");
|
||||
eprintln!("log called");
|
||||
eprintln!("log called");
|
||||
eprintln!("log called");
|
||||
self.wrapped.log(record);
|
||||
|
||||
let severity = match record.metadata().level() {
|
||||
@ -42,15 +44,16 @@ 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(),
|
||||
self.hostname.clone(),
|
||||
)
|
||||
..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() {
|
||||
if let Err(e) = client
|
||||
.post(&self.url)
|
||||
.json(&record)
|
||||
.timeout(Duration::from_secs(2))
|
||||
.send()
|
||||
{
|
||||
// TODO: log error (without sending it)
|
||||
eprintln!("failed to send log record: {e:?}");
|
||||
}
|
||||
@ -61,16 +64,6 @@ 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();
|
||||
39
snitch/src/message.rs
Normal file
39
snitch/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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,22 +1,17 @@
|
||||
[package]
|
||||
name = "snitch-web"
|
||||
name = "snitch_srv"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
snitch-lib = { path = "../snitch-lib" }
|
||||
snitch = { path = "../snitch" }
|
||||
|
||||
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"
|
||||
|
||||
[dev-dependencies]
|
||||
rand = "0.8.5"
|
||||
1
snitch_srv/migrations/2022-11-11T22:56.down.sql
Normal file
1
snitch_srv/migrations/2022-11-11T22:56.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE record;
|
||||
10
snitch_srv/migrations/2022-11-11T22:56.up.sql
Normal file
10
snitch_srv/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_srv/src/api.rs
Normal file
49
snitch_srv/src/api.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use rocket::{http::Status, serde::json::Json, State};
|
||||
use snitch::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_srv/src/dashboard.rs
Normal file
62
snitch_srv/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_srv/src/database.rs
Normal file
12
snitch_srv/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)
|
||||
}
|
||||
@ -5,27 +5,27 @@ mod api;
|
||||
mod dashboard;
|
||||
mod database;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use database::Database;
|
||||
use rocket_dyn_templates::Template;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Opt {
|
||||
/// Filepath where snitch will store its database.
|
||||
/// PostgreSQL connect string
|
||||
///
|
||||
/// e.g. postgresql://user:pass@localhost:5432/database
|
||||
#[clap(long, env = "SNITCH_DB", default_value = "./snitch.db")]
|
||||
db: PathBuf,
|
||||
#[clap(
|
||||
long,
|
||||
env = "DATABASE_URL",
|
||||
default_value = "postgresql://postgres@localhost:5432/postgres"
|
||||
)]
|
||||
db: String,
|
||||
}
|
||||
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
let opt = Opt::parse();
|
||||
color_eyre::install().unwrap();
|
||||
|
||||
let db = Database::open(&opt).expect("open database");
|
||||
let db = database::connect(&opt).await.expect("connect to database");
|
||||
|
||||
rocket::build()
|
||||
.manage(db)
|
||||
51
snitch_srv/templates/index.hbs
Normal file
51
snitch_srv/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>
|
||||
Reference in New Issue
Block a user