initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "clang"
|
||||||
|
rustflags = ["-C", "link-arg=-fuse-ld=mold", "-C", "target-cpu=skylake", '--cfg=curve25519_dalek_backend="simd"', '--cfg=feature="precomputed-tables"']
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
**/target
|
||||||
Generated
+2896
File diff suppressed because it is too large
Load Diff
+53
@@ -0,0 +1,53 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"prom-client",
|
||||||
|
"signal-gateway",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
# line-tables-only: keeps file/line info for backtraces, but strips variable/type debug info
|
||||||
|
# This significantly reduces binary size while preserving useful stack traces
|
||||||
|
debug = "line-tables-only"
|
||||||
|
# LTO eliminates dead code across crates, reducing binary size esp. for the AWS crates
|
||||||
|
# "thin" is faster than "fat" LTO while still providing most benefits
|
||||||
|
lto = "thin"
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
assigning_clones = "allow"
|
||||||
|
result_large_err = "allow"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
prom-client = { path = "prom-client" }
|
||||||
|
|
||||||
|
async-trait = "0.1"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||||
|
circular-buffer = "1.2"
|
||||||
|
conf = "0.4"
|
||||||
|
conf-extra = "0.1"
|
||||||
|
displaydoc = "0.2"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
futures-util = "0.3"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
humantime = "2"
|
||||||
|
hyper = { version = "1.7", features = ["server", "http1", "http2"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto"] }
|
||||||
|
jsonrpsee = { version = "0.26", features = ["macros", "async-client"] }
|
||||||
|
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "ttf", "datetime", "area_series", "line_series", "full_palette"] }
|
||||||
|
rand = "0.9"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
rust_decimal = { version = "1", features = ["serde-with-str"] }
|
||||||
|
rustls = "0.23"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
syslog_rfc5424 = "0.10"
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
url = "2"
|
||||||
|
walkdir = "2"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "prom-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
displaydoc = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
rust_decimal = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
|
||||||
|
plotters = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
conf = { workspace = true }
|
||||||
|
conf-extra = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["plot"]
|
||||||
|
plot = ["dep:plotters"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use super::QueryRangeRequest;
|
||||||
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
|
use std::{ops::Range, time::Duration};
|
||||||
|
|
||||||
|
pub struct QueryRangeRequestBuilder {
|
||||||
|
query: String,
|
||||||
|
range: Option<Range<DateTime<Utc>>>,
|
||||||
|
step: Option<Duration>,
|
||||||
|
count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryRangeRequestBuilder {
|
||||||
|
pub fn new(query: String) -> Self {
|
||||||
|
Self {
|
||||||
|
query,
|
||||||
|
range: None,
|
||||||
|
step: None,
|
||||||
|
count: 256,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn range(mut self, range: Range<DateTime<Utc>>) -> Self {
|
||||||
|
if self.range.is_some() {
|
||||||
|
panic!("already set range: {:?}", self.range);
|
||||||
|
}
|
||||||
|
self.range = Some(range);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn since(mut self, time: Duration) -> Self {
|
||||||
|
if self.range.is_some() {
|
||||||
|
panic!("already set range: {:?}", self.range);
|
||||||
|
}
|
||||||
|
let end = Utc::now();
|
||||||
|
let start = end - TimeDelta::from_std(time).unwrap();
|
||||||
|
|
||||||
|
self.range = Some(start..end);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn step(mut self, step: Duration) -> Self {
|
||||||
|
if self.step.is_some() {
|
||||||
|
panic!("already set step: {:?}", self.step);
|
||||||
|
}
|
||||||
|
self.step = Some(step);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(mut self, count: usize) -> Self {
|
||||||
|
self.count = count;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> QueryRangeRequest {
|
||||||
|
let query = self.query;
|
||||||
|
let range = self.range.unwrap();
|
||||||
|
|
||||||
|
let step = self.step.unwrap_or_else(|| {
|
||||||
|
let delta = range.end - range.start;
|
||||||
|
(delta / (self.count as i32)).to_std().unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryRangeRequest {
|
||||||
|
query,
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
step: step.as_secs_f64(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use displaydoc::Display;
|
||||||
|
use url::ParseError;
|
||||||
|
|
||||||
|
#[derive(Debug, Display)]
|
||||||
|
pub enum Error {
|
||||||
|
/// URL: {0}
|
||||||
|
Url(ParseError),
|
||||||
|
/// Reqwest: {0}
|
||||||
|
Reqwest(reqwest::Error),
|
||||||
|
/// API: {0}: {1}
|
||||||
|
API(String, String),
|
||||||
|
/// Unexpected Result Type: {0}
|
||||||
|
UnexpectedResultType(String),
|
||||||
|
/// Missing data on success response
|
||||||
|
MissingData,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for Error {
|
||||||
|
fn from(src: reqwest::Error) -> Self {
|
||||||
|
Self::Reqwest(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ParseError> for Error {
|
||||||
|
fn from(src: ParseError) -> Self {
|
||||||
|
Self::Url(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! Helpers for extracting common labels from metrics before presenting them
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
fmt::{Debug, Display},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A set of labels extracted by ExtractLabels
|
||||||
|
pub type Labels = BTreeMap<String, String>;
|
||||||
|
|
||||||
|
/// Common labels extracted from a very generic sequence of metric labels (key value pairs)
|
||||||
|
pub struct ExtractLabels {
|
||||||
|
pub name: String,
|
||||||
|
pub common_labels: Labels,
|
||||||
|
pub specific_labels: Vec<Labels>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractLabels {
|
||||||
|
pub fn new<'a, I, KV, K, V>(src: I, skip_labels: &[String]) -> Self
|
||||||
|
where
|
||||||
|
I: Iterator<Item = &'a KV>,
|
||||||
|
KV: Clone + Debug + 'a,
|
||||||
|
K: Display + 'a,
|
||||||
|
V: Display + 'a,
|
||||||
|
&'a KV: IntoIterator<Item = (&'a K, &'a V)>,
|
||||||
|
{
|
||||||
|
// Common labels needs to be String -> Option<String>, because when we find a conflict,
|
||||||
|
// we need to poison that key (by putting None)
|
||||||
|
let mut common_labels = BTreeMap::<String, Option<String>>::default();
|
||||||
|
let mut specific_labels: Vec<Labels> = src
|
||||||
|
.map(|kv| {
|
||||||
|
let labels: Labels = kv
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
let k = k.to_string();
|
||||||
|
let v = v.to_string();
|
||||||
|
if let Some(existing_val) = common_labels.get_mut(&k) {
|
||||||
|
// If the existing value is None, we already eliminated this label,
|
||||||
|
// so don't add it back.
|
||||||
|
if let Some(common_val) = existing_val
|
||||||
|
&& common_val != &v
|
||||||
|
{
|
||||||
|
*existing_val = None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
common_labels.insert(k.clone(), Some(v.clone()));
|
||||||
|
}
|
||||||
|
(k, v)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
labels
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut common_labels = common_labels
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(k, maybe_v)| maybe_v.map(|v| (k, v)))
|
||||||
|
.collect::<Labels>();
|
||||||
|
|
||||||
|
for sl in skip_labels {
|
||||||
|
common_labels.remove(sl);
|
||||||
|
}
|
||||||
|
for sp in &mut specific_labels {
|
||||||
|
for sl in skip_labels {
|
||||||
|
sp.remove(sl);
|
||||||
|
}
|
||||||
|
for cl in common_labels.keys() {
|
||||||
|
sp.remove(cl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = common_labels.remove("__name__").unwrap_or_default();
|
||||||
|
|
||||||
|
ExtractLabels {
|
||||||
|
name,
|
||||||
|
common_labels,
|
||||||
|
specific_labels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! API for getting time series data from prometheus
|
||||||
|
//!
|
||||||
|
//! To use it, instantiate one of the request objects,
|
||||||
|
//! e.g. QueryRequest or QueryRangeRequest. When it's helpful a builder is provided.
|
||||||
|
//!
|
||||||
|
//! Then use `PromRequest` trait and call `send` or `send_with_client`.
|
||||||
|
//! This takes the prometheus url, and optionally a reqwest client to use.
|
||||||
|
//!
|
||||||
|
//! On success, the result is `PromData`. One would usually call `into_matrix()?`
|
||||||
|
//! or `into_vector()?` as expected for the request that is made.
|
||||||
|
//!
|
||||||
|
//! In prometheus, metric labels are just a set of key-value pairs. However,
|
||||||
|
//! if you are expecting certain structure, you may use any KV object that implements
|
||||||
|
//! `serde::Deserialize` in the `PromData` that results from the call.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
mod builders;
|
||||||
|
pub use builders::QueryRangeRequestBuilder;
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
pub use error::Error;
|
||||||
|
|
||||||
|
mod labels;
|
||||||
|
pub use labels::{ExtractLabels, Labels};
|
||||||
|
|
||||||
|
mod messages;
|
||||||
|
use messages::PromResponse;
|
||||||
|
pub use messages::{
|
||||||
|
AlertInfo, AlertStatus, AlertsResponse, MetricTimeseries, MetricValue, PromData,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "plot")]
|
||||||
|
pub mod plot;
|
||||||
|
|
||||||
|
mod traits;
|
||||||
|
pub use traits::PromRequest;
|
||||||
|
|
||||||
|
/// Query parameters for /api/v1/query prometheus request
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct QueryRequest {
|
||||||
|
pub query: String,
|
||||||
|
pub time: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromRequest for QueryRequest {
|
||||||
|
const PATH: &str = "/api/v1/query";
|
||||||
|
type Output<KV: Clone + Debug + DeserializeOwned> = PromData<KV>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for /api/v1/query_range prometheus request
|
||||||
|
/// Use builder to populate it
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct QueryRangeRequest {
|
||||||
|
query: String,
|
||||||
|
start: DateTime<Utc>,
|
||||||
|
end: DateTime<Utc>,
|
||||||
|
step: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryRangeRequest {
|
||||||
|
/// Get builder for query range request with given query
|
||||||
|
pub fn builder(query: impl Into<String>) -> QueryRangeRequestBuilder {
|
||||||
|
QueryRangeRequestBuilder::new(query.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromRequest for QueryRangeRequest {
|
||||||
|
const PATH: &str = "/api/v1/query_range";
|
||||||
|
type Output<KV: Clone + Debug + DeserializeOwned> = PromData<KV>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for /api/v1/series prometheus request
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct SeriesRequest {
|
||||||
|
pub matches: MatchList,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromRequest for SeriesRequest {
|
||||||
|
const PATH: &str = "/api/v1/series";
|
||||||
|
type Output<KV: Clone + Debug + DeserializeOwned> = Vec<KV>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for /api/v1/labels prometheus request
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct LabelsRequest {
|
||||||
|
pub matches: MatchList,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromRequest for LabelsRequest {
|
||||||
|
const PATH: &str = "/api/v1/labels";
|
||||||
|
type Output<KV: Clone + Debug + DeserializeOwned> = Vec<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for /api/v1/alerts prometheus request
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AlertsRequest {}
|
||||||
|
|
||||||
|
impl PromRequest for AlertsRequest {
|
||||||
|
const PATH: &str = "/api/v1/alerts";
|
||||||
|
type Output<KV: Clone + Debug + DeserializeOwned> = AlertsResponse<KV>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a sequence of match[]=...,match[]=...
|
||||||
|
/// query parameters required by parts of prometheus api
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MatchList(Vec<String>);
|
||||||
|
|
||||||
|
impl Serialize for MatchList {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
use serde::ser::SerializeMap;
|
||||||
|
let mut map = serializer.serialize_map(Some(self.0.len()))?;
|
||||||
|
for v in &self.0 {
|
||||||
|
map.serialize_entry("match[]", v)?;
|
||||||
|
}
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<String>> for MatchList {
|
||||||
|
fn from(src: Vec<String>) -> Self {
|
||||||
|
Self(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromIterator<String> for MatchList {
|
||||||
|
fn from_iter<T>(iter: T) -> Self
|
||||||
|
where
|
||||||
|
T: IntoIterator<Item = String>,
|
||||||
|
{
|
||||||
|
Self(iter.into_iter().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// The prometheus http interface (at port 9090) renders graphs in JS and gets the raw data using API requests like this:
|
||||||
|
//
|
||||||
|
// GET http://localhost:9090/api/v1/query_range?query=tick_time{quantile="0.99"}&step=14&start=1762534433.802&end=1762538033.802
|
||||||
|
//
|
||||||
|
// Response is:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// status: "success"
|
||||||
|
// data: {
|
||||||
|
// resultType: "matrix",
|
||||||
|
// result: [
|
||||||
|
// {
|
||||||
|
// metric: { __name__: "tick_time", instance: "x.y.z.w", job: "ec2", .. },
|
||||||
|
// values: [
|
||||||
|
// [ 1762534433.802, "1.8974293514080933" ],
|
||||||
|
// [ 1762534447.802, "2.029724353457351" ],
|
||||||
|
// ..
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// For more detail see:
|
||||||
|
// https://prometheus.io/docs/prometheus/latest/querying/api/
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde::{Deserialize, de::DeserializeOwned};
|
||||||
|
use std::{collections::HashMap, fmt::Debug};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(bound = "KV: DeserializeOwned")]
|
||||||
|
pub struct MetricValue<KV = HashMap<String, String>>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug,
|
||||||
|
{
|
||||||
|
pub metric: KV,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value: Option<(f64, Decimal)>,
|
||||||
|
// TODO: Include histograms
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(bound = "KV: DeserializeOwned")]
|
||||||
|
pub struct MetricTimeseries<KV = HashMap<String, String>>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug,
|
||||||
|
{
|
||||||
|
pub metric: KV,
|
||||||
|
#[serde(default)]
|
||||||
|
pub values: Vec<(f64, Decimal)>,
|
||||||
|
// TODO: Include histograms
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(bound = "KV: DeserializeOwned")]
|
||||||
|
#[serde(tag = "resultType", content = "result", rename_all = "camelCase")]
|
||||||
|
pub enum PromData<KV = HashMap<String, String>>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug,
|
||||||
|
{
|
||||||
|
Matrix(Vec<MetricTimeseries<KV>>),
|
||||||
|
Vector(Vec<MetricValue<KV>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<KV> PromData<KV>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug,
|
||||||
|
{
|
||||||
|
pub fn into_matrix(self) -> Result<Vec<MetricTimeseries<KV>>, Error> {
|
||||||
|
match self {
|
||||||
|
Self::Matrix(data) => Ok(data),
|
||||||
|
_ => Err(Error::UnexpectedResultType(format!("{self:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_vector(self) -> Result<Vec<MetricValue<KV>>, Error> {
|
||||||
|
match self {
|
||||||
|
Self::Vector(data) => Ok(data),
|
||||||
|
_ => Err(Error::UnexpectedResultType(format!("{self:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Status {
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(bound = "T: DeserializeOwned", rename_all = "camelCase")]
|
||||||
|
pub struct PromResponse<T>
|
||||||
|
where
|
||||||
|
T: Clone + Debug,
|
||||||
|
{
|
||||||
|
pub status: Status,
|
||||||
|
#[serde(default)]
|
||||||
|
pub data: Option<T>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> PromResponse<T>
|
||||||
|
where
|
||||||
|
T: Clone + Debug,
|
||||||
|
{
|
||||||
|
pub fn into_result(self) -> Result<T, Error> {
|
||||||
|
for warning in self.warnings {
|
||||||
|
warn!("Prometheus API response: {warning}");
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.status {
|
||||||
|
Status::Success => Ok(self.data.ok_or(Error::MissingData)?),
|
||||||
|
Status::Error => Err(Error::API(
|
||||||
|
self.error_type.unwrap_or_default(),
|
||||||
|
self.error.unwrap_or_default(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(bound = "KV: DeserializeOwned")]
|
||||||
|
pub struct AlertsResponse<KV = HashMap<String, String>>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug,
|
||||||
|
{
|
||||||
|
pub alerts: Vec<AlertInfo<KV>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(bound = "KV: DeserializeOwned", rename_all = "camelCase")]
|
||||||
|
pub struct AlertInfo<KV = HashMap<String, String>>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug,
|
||||||
|
{
|
||||||
|
pub active_at: DateTime<Utc>,
|
||||||
|
pub annotations: KV,
|
||||||
|
pub labels: KV,
|
||||||
|
pub state: AlertStatus,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AlertStatus {
|
||||||
|
Pending,
|
||||||
|
Firing,
|
||||||
|
Resolved,
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
use crate::{ExtractLabels, MetricTimeseries};
|
||||||
|
use chrono::{DateTime, FixedOffset, TimeZone, Utc};
|
||||||
|
use plotters::prelude::*;
|
||||||
|
use rust_decimal::prelude::ToPrimitive;
|
||||||
|
use std::{
|
||||||
|
borrow::Borrow,
|
||||||
|
error::Error,
|
||||||
|
fmt::{Debug, Display, Write},
|
||||||
|
ops::Range,
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct PlotStyle {
|
||||||
|
pub drawing_area: (u32, u32),
|
||||||
|
pub background: RGBAColor,
|
||||||
|
pub grid: RGBAColor,
|
||||||
|
pub axis: RGBAColor,
|
||||||
|
pub text_color: RGBAColor,
|
||||||
|
pub text_font: String,
|
||||||
|
pub text_size: u32,
|
||||||
|
pub caption_size: u32,
|
||||||
|
pub data_colors: Vec<RGBAColor>,
|
||||||
|
pub threshold_color: RGBAColor,
|
||||||
|
pub skip_labels: Vec<String>,
|
||||||
|
pub utc_offset_hours: i32,
|
||||||
|
/// Optional title override - used when prometheus aggregations remove __name__
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlotStyle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
drawing_area: (1920, 1200),
|
||||||
|
background: WHITE.into(),
|
||||||
|
grid: RGBAColor(100, 100, 100, 0.5),
|
||||||
|
axis: BLACK.into(),
|
||||||
|
text_color: BLACK.into(),
|
||||||
|
text_font: "sans-serif".into(),
|
||||||
|
text_size: 18,
|
||||||
|
caption_size: 36,
|
||||||
|
data_colors: [
|
||||||
|
GREEN,
|
||||||
|
BLUE,
|
||||||
|
full_palette::ORANGE,
|
||||||
|
YELLOW,
|
||||||
|
MAGENTA,
|
||||||
|
full_palette::TEAL,
|
||||||
|
full_palette::PURPLE,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect(),
|
||||||
|
threshold_color: RED.mix(0.2),
|
||||||
|
skip_labels: vec!["job".into(), "instance".into()],
|
||||||
|
utc_offset_hours: -7,
|
||||||
|
title: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlotStyle {
|
||||||
|
pub fn with_title(mut self, title: impl Into<String>) -> Self {
|
||||||
|
self.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum PlotThreshold {
|
||||||
|
GreaterThan(f64),
|
||||||
|
LessThan(f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlotStyle {
|
||||||
|
pub fn dark_mode(mut self) -> Self {
|
||||||
|
self.background = BLACK.into();
|
||||||
|
self.grid = RGBAColor(100, 100, 100, 0.5);
|
||||||
|
self.axis = WHITE.into();
|
||||||
|
self.text_color = WHITE.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plot a collection of metric timeseries data from prometheus, and possibly a "threshold" defined in an alert.
|
||||||
|
/// Write the result to a path. The file extension of the path will determine the format, e.g. png, gif, etc.
|
||||||
|
pub fn plot_timeseries<KV, K, V>(
|
||||||
|
&self,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
mts: &[MetricTimeseries<KV>],
|
||||||
|
plot_threshold: Option<PlotThreshold>,
|
||||||
|
) -> Result<(), Box<dyn Error>>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug,
|
||||||
|
K: Display,
|
||||||
|
V: Display,
|
||||||
|
for<'a> &'a KV: IntoIterator<Item = (&'a K, &'a V)>,
|
||||||
|
{
|
||||||
|
// Prepare to plot by scanning the data, finding x and y bounds, common labels, etc.
|
||||||
|
let ExtractLabels {
|
||||||
|
name,
|
||||||
|
common_labels,
|
||||||
|
specific_labels,
|
||||||
|
} = ExtractLabels::new(mts.iter().map(|mts| &mts.metric), &self.skip_labels);
|
||||||
|
let PreparedPlot {
|
||||||
|
x_range,
|
||||||
|
y_range,
|
||||||
|
ts,
|
||||||
|
} = PreparedPlot::prepare(mts)?;
|
||||||
|
|
||||||
|
// Figure out the caption for formatting style for date-times
|
||||||
|
// Use title override if provided, otherwise use extracted metric name
|
||||||
|
// Only append common_labels if no title override (since title likely already has labels)
|
||||||
|
let mut caption = if let Some(title) = &self.title {
|
||||||
|
title.clone()
|
||||||
|
} else {
|
||||||
|
let mut c = name;
|
||||||
|
if !common_labels.is_empty() {
|
||||||
|
write!(&mut c, " {common_labels:?}")?;
|
||||||
|
}
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date-times differently depending on the range of date-times being displayed.
|
||||||
|
//
|
||||||
|
// If they are all on the same day, then omit the day, and put it in the caption instead
|
||||||
|
let timezone =
|
||||||
|
FixedOffset::east_opt(3600 * self.utc_offset_hours).ok_or("invalid timezone")?;
|
||||||
|
let start_date_naive = x_range.start.with_timezone(&timezone).date_naive();
|
||||||
|
let date_format_str =
|
||||||
|
if start_date_naive == x_range.end.with_timezone(&timezone).date_naive() {
|
||||||
|
write!(&mut caption, " {start_date_naive}")?;
|
||||||
|
"%H:%M:%S"
|
||||||
|
} else {
|
||||||
|
"%m/%d %H:%M:%S"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add timezone offset to the caption
|
||||||
|
write!(&mut caption, " UTC{o}", o = self.utc_offset_hours)?;
|
||||||
|
|
||||||
|
// Actually start writing the file
|
||||||
|
let root_area = BitMapBackend::new(&path, self.drawing_area).into_drawing_area();
|
||||||
|
root_area.fill(&self.background)?;
|
||||||
|
|
||||||
|
let mut ctx = ChartBuilder::on(&root_area)
|
||||||
|
.set_label_area_size(LabelAreaPosition::Left, 100)
|
||||||
|
.set_label_area_size(LabelAreaPosition::Bottom, 40)
|
||||||
|
.caption(
|
||||||
|
caption,
|
||||||
|
(self.text_font.as_str(), self.caption_size, &self.text_color)
|
||||||
|
.into_text_style(&root_area),
|
||||||
|
)
|
||||||
|
.build_cartesian_2d(x_range.clone(), y_range.clone())?;
|
||||||
|
|
||||||
|
let text_style =
|
||||||
|
(self.text_font.as_str(), self.text_size, &self.text_color).into_text_style(&root_area);
|
||||||
|
|
||||||
|
ctx.configure_mesh()
|
||||||
|
.light_line_style(self.grid) // Dark gray grid lines
|
||||||
|
.axis_style(self.axis) // White axis lines
|
||||||
|
.bold_line_style(self.axis) // White bold lines
|
||||||
|
.label_style(text_style.clone())
|
||||||
|
.x_label_formatter(&|x| {
|
||||||
|
x.with_timezone(&timezone)
|
||||||
|
.format(date_format_str)
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.draw()?;
|
||||||
|
|
||||||
|
if let Some(threshold) = plot_threshold {
|
||||||
|
let (limit, baseline) = match threshold {
|
||||||
|
PlotThreshold::GreaterThan(limit) => (limit, y_range.end),
|
||||||
|
PlotThreshold::LessThan(limit) => (limit, y_range.start),
|
||||||
|
};
|
||||||
|
ctx.draw_series(AreaSeries::new(
|
||||||
|
[(x_range.start, limit), (x_range.end, limit)],
|
||||||
|
baseline,
|
||||||
|
self.threshold_color,
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show legend if there are multiple series (single series doesn't need a legend)
|
||||||
|
let show_legend = specific_labels.len() > 1;
|
||||||
|
|
||||||
|
for (idx, (mut metric, vals)) in specific_labels.into_iter().zip(ts.into_iter()).enumerate()
|
||||||
|
{
|
||||||
|
let color = &self.data_colors[idx % self.data_colors.len()];
|
||||||
|
|
||||||
|
let name = metric.remove("__name__").unwrap_or_default();
|
||||||
|
let label = format!("{name} {metric:?}");
|
||||||
|
|
||||||
|
let series = ctx.draw_series(LineSeries::new(vals, color))?;
|
||||||
|
if show_legend {
|
||||||
|
series
|
||||||
|
.label(label)
|
||||||
|
.legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if show_legend {
|
||||||
|
ctx.configure_series_labels()
|
||||||
|
.position(SeriesLabelPosition::LowerLeft)
|
||||||
|
.border_style(self.axis)
|
||||||
|
.background_style(self.background.mix(0.8))
|
||||||
|
.label_font(text_style)
|
||||||
|
.draw()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal any errors that occurred when writing the file
|
||||||
|
// https://github.com/plotters-rs/plotters?tab=readme-ov-file#faq-list
|
||||||
|
root_area.present()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreparedPlot {
|
||||||
|
x_range: Range<DateTime<Utc>>,
|
||||||
|
y_range: Range<f64>,
|
||||||
|
ts: Vec<Vec<(DateTime<Utc>, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreparedPlot {
|
||||||
|
fn prepare<KV>(data: &[impl Borrow<MetricTimeseries<KV>>]) -> Result<Self, &'static str>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug,
|
||||||
|
{
|
||||||
|
let mut x_range = None;
|
||||||
|
let mut y_range = None;
|
||||||
|
|
||||||
|
let ts: Vec<Vec<(_, f64)>> = data
|
||||||
|
.iter()
|
||||||
|
.map(|mts| {
|
||||||
|
let mts = mts.borrow();
|
||||||
|
|
||||||
|
mts.values
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, v)| {
|
||||||
|
let x = f64_to_datetime(k)?;
|
||||||
|
let y = v.to_f64()?;
|
||||||
|
extend_range(&mut x_range, &x);
|
||||||
|
extend_range(&mut y_range, &y);
|
||||||
|
|
||||||
|
Some((x, y))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let x_range = x_range.ok_or("No data")?;
|
||||||
|
let y_range = y_range.ok_or("No data")?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
x_range,
|
||||||
|
y_range,
|
||||||
|
ts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn f64_to_datetime(t: &f64) -> Option<DateTime<Utc>> {
|
||||||
|
let seconds = t.trunc() as i64;
|
||||||
|
let nanoseconds = (t.fract() * 1_000_000_000.0) as u32;
|
||||||
|
|
||||||
|
Utc.timestamp_opt(seconds, nanoseconds).single()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extend_range<T: PartialOrd + Clone>(range: &mut Option<Range<T>>, val: &T) {
|
||||||
|
if let Some(range) = range.as_mut() {
|
||||||
|
if range.start > *val {
|
||||||
|
range.start = val.clone();
|
||||||
|
} else if range.end < *val {
|
||||||
|
range.end = val.clone();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*range = Some(val.clone()..val.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use crate::{Error, PromResponse};
|
||||||
|
use reqwest::{Client, Url};
|
||||||
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait PromRequest: Serialize {
|
||||||
|
const PATH: &str;
|
||||||
|
type Output<KV: Clone + Debug + DeserializeOwned>: Clone + Debug + DeserializeOwned;
|
||||||
|
|
||||||
|
async fn send<KV>(&self, host: &str) -> Result<Self::Output<KV>, Error>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug + DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.send_with_client(&Client::new(), host).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_with_client<KV>(
|
||||||
|
&self,
|
||||||
|
client: &Client,
|
||||||
|
host: &str,
|
||||||
|
) -> Result<Self::Output<KV>, Error>
|
||||||
|
where
|
||||||
|
KV: Clone + Debug + DeserializeOwned,
|
||||||
|
{
|
||||||
|
let url = Url::parse(host)?.join(Self::PATH)?;
|
||||||
|
|
||||||
|
let resp: PromResponse<Self::Output<KV>> =
|
||||||
|
client.get(url).query(&self).send().await?.json().await?;
|
||||||
|
|
||||||
|
let data = resp.into_result()?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[package]
|
||||||
|
name = "signal-gateway"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
prom-client = { workspace = true }
|
||||||
|
|
||||||
|
chrono = { workspace = true }
|
||||||
|
circular-buffer = { workspace = true }
|
||||||
|
conf = { workspace = true }
|
||||||
|
conf-extra = { workspace = true }
|
||||||
|
displaydoc = { workspace = true }
|
||||||
|
dotenvy = { workspace = true }
|
||||||
|
futures-util = { workspace = true }
|
||||||
|
http-body-util = { workspace = true }
|
||||||
|
humantime = { workspace = true }
|
||||||
|
hyper = { workspace = true }
|
||||||
|
hyper-util = { workspace = true }
|
||||||
|
jsonrpsee = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
rust_decimal = { workspace = true }
|
||||||
|
rustls = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
syslog_rfc5424 = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tokio-util = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
walkdir = { workspace = true }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use crate::gateway::GatewayConfig;
|
||||||
|
use conf::Conf;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
#[derive(Conf, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
/// If true, just validate config and don't start
|
||||||
|
#[conf(long)]
|
||||||
|
pub dry_run: bool,
|
||||||
|
/// Socket to listen for HTTP requests (GET /health, POST /alert)
|
||||||
|
#[conf(long, env, default_value = "0.0.0.0:8000")]
|
||||||
|
pub http_listen_addr: SocketAddr,
|
||||||
|
/// Socket to listen for UDP messages, in syslog RFC 5424 format
|
||||||
|
#[conf(long, env, default_value = "0.0.0.0:5424")]
|
||||||
|
pub udp_listen_addr: SocketAddr,
|
||||||
|
#[conf(flatten)]
|
||||||
|
pub gateway: GatewayConfig,
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
use super::{AdminMessage, MultiRateLimiter, Origin, RateThreshold, SourceLocationRateLimiter};
|
||||||
|
use crate::human_duration::HumanTMinus;
|
||||||
|
use chrono::{TimeDelta, Utc};
|
||||||
|
use circular_buffer::CircularBuffer;
|
||||||
|
use conf::Conf;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{fmt, time::Duration};
|
||||||
|
use syslog_rfc5424::{SyslogMessage, SyslogSeverity};
|
||||||
|
use tokio::sync::{Mutex, mpsc::UnboundedSender};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Reason why an alert was suppressed
|
||||||
|
enum SuppressionReason {
|
||||||
|
/// Suppressed by a configured alert rule (with 0-based rule index)
|
||||||
|
Rule(usize),
|
||||||
|
/// Suppressed by the source-location rate limiter
|
||||||
|
SourceLocation { file: String, line: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SuppressionReason {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
SuppressionReason::Rule(idx) => write!(f, "rule[{idx}]"),
|
||||||
|
SuppressionReason::SourceLocation { file, line } => {
|
||||||
|
write!(f, "source-location({file}:{line})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config options related to the log handler, and what log messages it chooses to alert on.
|
||||||
|
#[derive(Clone, Conf, Debug)]
|
||||||
|
pub struct LogHandlerConfig {
|
||||||
|
#[conf(long, env, value_parser = serde_json::from_str)]
|
||||||
|
pub alert_rate_limits: Vec<AlertRule>,
|
||||||
|
#[conf(long, env, default_value = "10m", value_parser = conf_extra::parse_duration)]
|
||||||
|
pub overall_alert_limit: Duration,
|
||||||
|
#[conf(long, env)]
|
||||||
|
pub format_module: bool,
|
||||||
|
#[conf(long, env)]
|
||||||
|
pub format_source_location: bool,
|
||||||
|
/// Structured data ID for tracing metadata (module, file, line) in syslog messages
|
||||||
|
#[conf(long, env, default_value = "tracing-meta@64700")]
|
||||||
|
pub sd_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies both a rate limiting threshold, and criteria for the threshold to apply
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AlertRule {
|
||||||
|
#[serde(default)]
|
||||||
|
pub msg_contains: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub module_equals: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_equals: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub line_equals: String,
|
||||||
|
pub threshold: RateThreshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlertRule {
|
||||||
|
/// Check if a syslog message passes the filter defined by this rule
|
||||||
|
fn eval_filter(&self, syslog_msg: &SyslogMessage, sd_id: &str) -> bool {
|
||||||
|
if !self.msg_contains.is_empty() && !syslog_msg.msg.contains(&self.msg_contains) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.module_equals.is_empty() {
|
||||||
|
match syslog_msg.sd.find_tuple(sd_id, "module") {
|
||||||
|
Some(module) if module == self.module_equals.as_str() => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.file_equals.is_empty() {
|
||||||
|
match syslog_msg.sd.find_tuple(sd_id, "file") {
|
||||||
|
Some(file) if file == self.file_equals.as_str() => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.line_equals.is_empty() {
|
||||||
|
match syslog_msg.sd.find_tuple(sd_id, "line") {
|
||||||
|
Some(line) if line == self.line_equals.as_str() => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The log handler takes log messages from a single origin and decides what
|
||||||
|
/// to do with them.
|
||||||
|
///
|
||||||
|
/// 1. Store them in a small circular buffer
|
||||||
|
/// 2. If it is an error, and meets other criteria, trigger an alert,
|
||||||
|
/// i.e. send a message to admins containing this log and other recent logs.
|
||||||
|
/// 3. The maximum rate of alerts can also be configured.
|
||||||
|
///
|
||||||
|
/// Additionally, the log handler can format the buffer of recent logs into a string,
|
||||||
|
/// if requested.
|
||||||
|
///
|
||||||
|
/// Each origin (app + host pair) gets its own LogHandler instance, managed by the Gateway.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LogHandler {
|
||||||
|
config: LogHandlerConfig,
|
||||||
|
admin_mq_tx: UnboundedSender<AdminMessage>,
|
||||||
|
syslog_buffer: Mutex<CircularBuffer<64, SyslogMessage>>,
|
||||||
|
rate_limiters: Vec<(AlertRule, Mutex<MultiRateLimiter>)>,
|
||||||
|
/// Rate limiter keyed by source location (file:line), so different error locations
|
||||||
|
/// can alert independently without suppressing each other.
|
||||||
|
overall_limiter: Mutex<SourceLocationRateLimiter>,
|
||||||
|
/// True if any configured rule uses the module structured data field.
|
||||||
|
any_rule_uses_module: bool,
|
||||||
|
/// True if any configured rule uses the file structured data field.
|
||||||
|
any_rule_uses_file: bool,
|
||||||
|
/// True if any configured rule uses the line structured data field.
|
||||||
|
any_rule_uses_line: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum entries in the source-location rate limiter before triggering cleanup
|
||||||
|
const OVERALL_LIMITER_MAX_ENTRIES: usize = 2000;
|
||||||
|
|
||||||
|
impl LogHandler {
|
||||||
|
/// Initialize a new log handler
|
||||||
|
pub fn new(config: LogHandlerConfig, admin_mq_tx: UnboundedSender<AdminMessage>) -> Self {
|
||||||
|
let any_rule_uses_module = config
|
||||||
|
.alert_rate_limits
|
||||||
|
.iter()
|
||||||
|
.any(|r| !r.module_equals.is_empty());
|
||||||
|
let any_rule_uses_file = config
|
||||||
|
.alert_rate_limits
|
||||||
|
.iter()
|
||||||
|
.any(|r| !r.file_equals.is_empty());
|
||||||
|
let any_rule_uses_line = config
|
||||||
|
.alert_rate_limits
|
||||||
|
.iter()
|
||||||
|
.any(|r| !r.line_equals.is_empty());
|
||||||
|
let rate_limiters = config
|
||||||
|
.alert_rate_limits
|
||||||
|
.iter()
|
||||||
|
.map(|rule| {
|
||||||
|
(
|
||||||
|
rule.clone(),
|
||||||
|
Mutex::new(MultiRateLimiter::from(rule.threshold)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let overall_limiter = Mutex::new(SourceLocationRateLimiter::new(
|
||||||
|
config.overall_alert_limit,
|
||||||
|
OVERALL_LIMITER_MAX_ENTRIES,
|
||||||
|
));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
admin_mq_tx,
|
||||||
|
syslog_buffer: Default::default(),
|
||||||
|
rate_limiters,
|
||||||
|
overall_limiter,
|
||||||
|
any_rule_uses_module,
|
||||||
|
any_rule_uses_file,
|
||||||
|
any_rule_uses_line,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format recent logs into a string
|
||||||
|
pub async fn format_logs(&self) -> String {
|
||||||
|
let lk = self.syslog_buffer.lock().await;
|
||||||
|
|
||||||
|
let mut text = format!("{} log messages (newest first):\n", lk.len());
|
||||||
|
|
||||||
|
// Calculate now once for consistent relative timestamps
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
|
||||||
|
// Collect and reverse to show newest first
|
||||||
|
let messages: Vec<_> = lk.iter().collect();
|
||||||
|
for syslog_msg in messages.into_iter().rev() {
|
||||||
|
self.write_syslog_msg(&mut text, syslog_msg, now);
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume a new syslog message from the given origin
|
||||||
|
pub async fn handle_syslog_message(&self, mut syslog_msg: SyslogMessage, origin: Origin) {
|
||||||
|
let suppression_reason = self.check_suppression(&mut syslog_msg).await;
|
||||||
|
if let Some(reason) = &suppression_reason
|
||||||
|
&& syslog_msg.severity <= SyslogSeverity::SEV_ERR
|
||||||
|
{
|
||||||
|
let sev = Self::severity_to_str(syslog_msg.severity);
|
||||||
|
info!("Suppressed {sev} ({reason}):\n{}", syslog_msg.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this new message.
|
||||||
|
// Then, if we should alert now, also format the whole buffer to a string,
|
||||||
|
// and then release the lock.
|
||||||
|
let formatted_text = {
|
||||||
|
let mut lk = self.syslog_buffer.lock().await;
|
||||||
|
lk.push_back(syslog_msg);
|
||||||
|
if suppression_reason.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut text = String::default();
|
||||||
|
|
||||||
|
// Calculate now once for consistent relative timestamps
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
|
||||||
|
// Iterate in reverse (newest first) without copying
|
||||||
|
for syslog_msg in lk.iter().rev() {
|
||||||
|
self.write_syslog_msg(&mut text, syslog_msg, now);
|
||||||
|
}
|
||||||
|
lk.clear();
|
||||||
|
|
||||||
|
text
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(_err) = self.admin_mq_tx.send(AdminMessage {
|
||||||
|
origin: Some(origin),
|
||||||
|
text: formatted_text,
|
||||||
|
attachment_paths: Default::default(),
|
||||||
|
summary: None,
|
||||||
|
}) {
|
||||||
|
error!("Could not send alert message, queue is closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert SyslogSeverity to our own all-caps string that fits in 5 chars
|
||||||
|
fn severity_to_str(severity: SyslogSeverity) -> &'static str {
|
||||||
|
match severity {
|
||||||
|
SyslogSeverity::SEV_EMERG => "EMERG",
|
||||||
|
SyslogSeverity::SEV_ALERT => "ALERT",
|
||||||
|
SyslogSeverity::SEV_CRIT => "CRIT",
|
||||||
|
SyslogSeverity::SEV_ERR => "ERROR",
|
||||||
|
SyslogSeverity::SEV_WARNING => "WARN",
|
||||||
|
SyslogSeverity::SEV_NOTICE => "NOTE",
|
||||||
|
SyslogSeverity::SEV_INFO => "INFO",
|
||||||
|
SyslogSeverity::SEV_DEBUG => "DEBUG",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a syslog message into a Writer, followed by \n, and using any config options to do so
|
||||||
|
fn write_syslog_msg(
|
||||||
|
&self,
|
||||||
|
mut writer: impl std::fmt::Write,
|
||||||
|
syslog_msg: &SyslogMessage,
|
||||||
|
now: i64,
|
||||||
|
) {
|
||||||
|
let sev = Self::severity_to_str(syslog_msg.severity);
|
||||||
|
let msg = &syslog_msg.msg;
|
||||||
|
|
||||||
|
// Format relative timestamp if available
|
||||||
|
let time_str = if let Some(ts) = syslog_msg.timestamp {
|
||||||
|
let diff_secs = now.saturating_sub(ts);
|
||||||
|
HumanTMinus(TimeDelta::seconds(diff_secs)).to_string()
|
||||||
|
} else {
|
||||||
|
"T-?".to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract metadata from structured data if configured
|
||||||
|
let sd_id = &self.config.sd_id;
|
||||||
|
let mut metadata_parts = Vec::new();
|
||||||
|
|
||||||
|
if self.config.format_module
|
||||||
|
&& let Some(module) = syslog_msg.sd.find_tuple(sd_id, "module")
|
||||||
|
{
|
||||||
|
metadata_parts.push(module.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.format_source_location {
|
||||||
|
let file_opt = syslog_msg.sd.find_tuple(sd_id, "file");
|
||||||
|
let line_opt = syslog_msg.sd.find_tuple(sd_id, "line");
|
||||||
|
|
||||||
|
let location = if let Some(file) = file_opt {
|
||||||
|
// Strip /home/{username}/ prefix if present
|
||||||
|
let trimmed_file = strip_prefix_and_one_slash(file, "/home/");
|
||||||
|
// Strip .cargo/registry/src/{hash}/ if present
|
||||||
|
let trimmed_file = strip_prefix_and_one_slash(trimmed_file, ".cargo/registry/src/");
|
||||||
|
|
||||||
|
if let Some(line) = line_opt {
|
||||||
|
format!("{trimmed_file}:{line}")
|
||||||
|
} else {
|
||||||
|
format!("{trimmed_file}:?")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No file present, use "?" even if line is present
|
||||||
|
"?".to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
metadata_parts.push(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: "ERROR T-10s [foo bar.rs:42]: message"
|
||||||
|
// Pad severity to 5 chars (left-aligned), time to 8 chars (right-aligned)
|
||||||
|
let result = if metadata_parts.is_empty() {
|
||||||
|
writeln!(writer, "{:<5} {:>8}: {}", sev, time_str, msg)
|
||||||
|
} else {
|
||||||
|
let metadata = metadata_parts.join(" ");
|
||||||
|
writeln!(writer, "{:<5} {:>8} [{}]: {}", sev, time_str, metadata, msg)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
error!("Couldn't write syslog message ({err}): {sev}: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an alert should be suppressed for a given error message.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the alert should fire, or `Some(reason)` if suppressed.
|
||||||
|
async fn check_suppression(&self, syslog_msg: &mut SyslogMessage) -> Option<SuppressionReason> {
|
||||||
|
let ts_sec = *syslog_msg
|
||||||
|
.timestamp
|
||||||
|
.get_or_insert_with(|| Utc::now().timestamp());
|
||||||
|
|
||||||
|
let high_severity = syslog_msg.severity <= SyslogSeverity::SEV_ERR;
|
||||||
|
if !high_severity {
|
||||||
|
// Low severity messages are always "suppressed" (not alerted on)
|
||||||
|
// but we don't need to log a reason for this
|
||||||
|
return Some(SuppressionReason::Rule(usize::MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sd_id = &self.config.sd_id;
|
||||||
|
|
||||||
|
// Warn if rules expect structured data but the message doesn't have it
|
||||||
|
let has_module = syslog_msg.sd.find_tuple(sd_id, "module").is_some();
|
||||||
|
let has_file = syslog_msg.sd.find_tuple(sd_id, "file").is_some();
|
||||||
|
let has_line = syslog_msg.sd.find_tuple(sd_id, "line").is_some();
|
||||||
|
if (self.any_rule_uses_module && !has_module)
|
||||||
|
|| (self.any_rule_uses_file && !has_file)
|
||||||
|
|| (self.any_rule_uses_line && !has_line)
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"Error message missing structured data (sd_id={sd_id}), filtering rules may not work: {syslog_msg:#?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each configured rule - track which rule suppressed the alert
|
||||||
|
// Note: we check all rules even if one already suppressed, to update all rate limiters
|
||||||
|
let mut suppressed_by_rule: Option<usize> = None;
|
||||||
|
for (idx, (filter, limiter)) in self.rate_limiters.iter().enumerate() {
|
||||||
|
if filter.eval_filter(syslog_msg, sd_id) && !limiter.lock().await.evaluate(ts_sec) {
|
||||||
|
suppressed_by_rule.get_or_insert(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(idx) = suppressed_by_rule {
|
||||||
|
return Some(SuppressionReason::Rule(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract source location for per-location rate limiting
|
||||||
|
let file = syslog_msg
|
||||||
|
.sd
|
||||||
|
.find_tuple(sd_id, "file")
|
||||||
|
.map_or("?", |s| s.as_str());
|
||||||
|
let line = syslog_msg
|
||||||
|
.sd
|
||||||
|
.find_tuple(sd_id, "line")
|
||||||
|
.map_or("?", |s| s.as_str());
|
||||||
|
|
||||||
|
if !self
|
||||||
|
.overall_limiter
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.evaluate(file, line, ts_sec)
|
||||||
|
{
|
||||||
|
return Some(SuppressionReason::SourceLocation {
|
||||||
|
file: file.to_owned(),
|
||||||
|
line: line.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None // Alert should fire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip a prefix, then find the first remaining slash and skip up to that as well.
|
||||||
|
fn strip_prefix_and_one_slash<'a>(target: &'a str, prefix: &str) -> &'a str {
|
||||||
|
let Some(target) = target.strip_prefix(prefix) else {
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((_, after)) = target.split_once('/') {
|
||||||
|
after
|
||||||
|
} else {
|
||||||
|
target
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,857 @@
|
|||||||
|
use crate::{
|
||||||
|
http::{AlertMessage, Status},
|
||||||
|
jsonrpc::{Envelope, RpcClient, RpcClientError, SignalMessage, connect_tcp},
|
||||||
|
plotter::{Plotter, PlotterConfig},
|
||||||
|
};
|
||||||
|
use conf::{Conf, Subcommands};
|
||||||
|
use futures_util::FutureExt;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use hyper::{Method, Request, Response, StatusCode, body::Incoming};
|
||||||
|
use prom_client::{AlertStatus, ExtractLabels};
|
||||||
|
//use jsonrpsee::async_client::{Client as JsonRpcClient, Error as JsonRpcError};
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::{collections::HashMap, error::Error, fmt::Write, net::SocketAddr, time::Duration};
|
||||||
|
use syslog_rfc5424::SyslogMessage;
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||||
|
join,
|
||||||
|
net::TcpStream,
|
||||||
|
sync::{
|
||||||
|
Mutex, RwLock,
|
||||||
|
mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel},
|
||||||
|
},
|
||||||
|
time::timeout,
|
||||||
|
};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
mod log_handler;
|
||||||
|
use log_handler::{LogHandler, LogHandlerConfig};
|
||||||
|
|
||||||
|
mod rate_limiter;
|
||||||
|
use rate_limiter::{MultiRateLimiter, RateThreshold, SourceLocationRateLimiter};
|
||||||
|
|
||||||
|
#[derive(Conf, Debug)]
|
||||||
|
pub struct GatewayConfig {
|
||||||
|
#[conf(long, env, default_value = "127.0.0.1:7583")]
|
||||||
|
pub signal_cli_tcp_addr: SocketAddr,
|
||||||
|
#[conf(long, env)]
|
||||||
|
pub signal_account: String,
|
||||||
|
#[conf(long, env)]
|
||||||
|
pub cbmm_tcp_addr: String,
|
||||||
|
#[conf(long, env, default_value = "5s", value_parser = conf_extra::parse_duration)]
|
||||||
|
pub cbmm_timeout: Duration,
|
||||||
|
#[conf(repeat, long, env)]
|
||||||
|
pub admin_uuid: Vec<String>,
|
||||||
|
#[conf(flatten)]
|
||||||
|
pub plotter: Option<PlotterConfig>,
|
||||||
|
#[conf(flatten)]
|
||||||
|
pub log_handler: LogHandlerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper for parsing gateway commands
|
||||||
|
#[derive(Clone, Debug, Conf)]
|
||||||
|
struct GatewayCommandWrapper {
|
||||||
|
#[conf(subcommands)]
|
||||||
|
command: GatewayCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commands that can be sent to the gateway (prefixed with /)
|
||||||
|
#[derive(Clone, Debug, Subcommands)]
|
||||||
|
enum GatewayCommand {
|
||||||
|
/// Show recent log messages
|
||||||
|
#[conf(name = "log", alias = "LOG")]
|
||||||
|
Log {
|
||||||
|
/// Optional filter: show only origins where app or host contains this string
|
||||||
|
#[conf(pos)]
|
||||||
|
filter: Option<String>,
|
||||||
|
},
|
||||||
|
/// Query prometheus for current values
|
||||||
|
#[conf(name = "query", alias = "QUERY")]
|
||||||
|
Query {
|
||||||
|
/// PromQL query expression
|
||||||
|
#[conf(pos)]
|
||||||
|
query: String,
|
||||||
|
},
|
||||||
|
/// Plot a prometheus query over time
|
||||||
|
#[conf(name = "plot", alias = "PLOT")]
|
||||||
|
Plot {
|
||||||
|
/// PromQL query expression
|
||||||
|
#[conf(pos)]
|
||||||
|
query: String,
|
||||||
|
/// Duration to plot (e.g., 1h, 24h)
|
||||||
|
#[conf(long, short = 'd', default_value = "1h", value_parser = conf_extra::parse_duration)]
|
||||||
|
duration: Duration,
|
||||||
|
},
|
||||||
|
/// List series matching label patterns
|
||||||
|
#[conf(name = "series", alias = "SERIES")]
|
||||||
|
Series {
|
||||||
|
/// Label matchers (e.g., __name__=~".*requests.*")
|
||||||
|
#[conf(repeat, pos)]
|
||||||
|
matchers: Vec<String>,
|
||||||
|
},
|
||||||
|
/// List label names matching patterns
|
||||||
|
#[conf(name = "labels", alias = "LABELS")]
|
||||||
|
Labels {
|
||||||
|
/// Label matchers
|
||||||
|
#[conf(repeat, pos)]
|
||||||
|
matchers: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Show current alerts from prometheus
|
||||||
|
#[conf(name = "alerts", alias = "ALERTS")]
|
||||||
|
Alerts,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a gateway command from a string (with or without leading /)
|
||||||
|
fn parse_gateway_command(s: &str) -> Result<GatewayCommand, String> {
|
||||||
|
// Remove leading slash if present
|
||||||
|
let s = s.strip_prefix('/').unwrap_or(s).trim();
|
||||||
|
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err("Empty command".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse using Conf, treating the input as command line arguments
|
||||||
|
// Prepend a dummy program name since Conf expects argv[0]
|
||||||
|
let args = std::iter::once("gateway")
|
||||||
|
.chain(s.split_whitespace())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
GatewayCommandWrapper::try_parse_from::<&str, &str, &str>(args, vec![])
|
||||||
|
.map(|wrapper| wrapper.command)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifies the source of log messages (app name + host).
|
||||||
|
/// Used to separate log buffers and rate limiters per source.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Origin {
|
||||||
|
pub app: String,
|
||||||
|
pub host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Origin {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}@{}", self.app, self.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&SyslogMessage> for Origin {
|
||||||
|
fn from(msg: &SyslogMessage) -> Self {
|
||||||
|
Self {
|
||||||
|
app: msg.appname.clone().unwrap_or_default(),
|
||||||
|
host: msg.hostname.clone().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Origin {
|
||||||
|
/// Check if this origin matches a filter string.
|
||||||
|
///
|
||||||
|
/// If the filter contains '@', it is split on the first '@':
|
||||||
|
/// - The part before '@' must be a substring of `app`
|
||||||
|
/// - The part after '@' must be a substring of `host`
|
||||||
|
///
|
||||||
|
/// If the filter does not contain '@', it matches if either `app` or `host`
|
||||||
|
/// contains the filter string.
|
||||||
|
pub fn matches_filter(&self, filter: &str) -> bool {
|
||||||
|
if let Some((app_filter, host_filter)) = filter.split_once('@') {
|
||||||
|
self.app.contains(app_filter) && self.host.contains(host_filter)
|
||||||
|
} else {
|
||||||
|
self.app.contains(filter) || self.host.contains(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message queued to be sent to all admins.
|
||||||
|
/// This is generally an alert message, which may have attached images.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct AdminMessage {
|
||||||
|
/// The origin of the message (app + host), if from syslog
|
||||||
|
origin: Option<Origin>,
|
||||||
|
text: String,
|
||||||
|
attachment_paths: Vec<String>,
|
||||||
|
/// Short summary for logging (e.g., alert names for prometheus).
|
||||||
|
/// If None, the consumer will use a truncated slice of `text` for logging.
|
||||||
|
summary: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gateway manages sending messages to signal-cli and receiving messages from signal-cli.
|
||||||
|
/// It maintains a queue of messages to be sent to all admins, generated by alerts etc.
|
||||||
|
/// It also subscribes to messages received from signal and processes them one-by-one, possibly
|
||||||
|
/// making TCP request to cbmm to handle them.
|
||||||
|
///
|
||||||
|
/// This is the only task that communicates directly with signal-cli, and by design it linearizes
|
||||||
|
/// all interaction, which prevents any possible races.
|
||||||
|
///
|
||||||
|
/// The gateway also maintains a buffer of at most 64 syslog messages that it has received.
|
||||||
|
/// If an error occurs, that message and the previous messages in the buffer are sent to signal admins,
|
||||||
|
/// and then the buffer is purged. This serves as a minimal log-aggregation and alerting system.
|
||||||
|
pub struct Gateway {
|
||||||
|
config: GatewayConfig,
|
||||||
|
admin_mq_tx: UnboundedSender<AdminMessage>,
|
||||||
|
admin_mq_rx: Mutex<UnboundedReceiver<AdminMessage>>,
|
||||||
|
token: CancellationToken,
|
||||||
|
plotter: Option<Plotter>,
|
||||||
|
/// Log handlers keyed by origin (app + host). Lazily created when first message from an origin arrives.
|
||||||
|
log_handlers: RwLock<HashMap<Origin, LogHandler>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gateway {
|
||||||
|
pub async fn new(config: GatewayConfig, token: CancellationToken) -> Self {
|
||||||
|
let (admin_mq_tx, admin_mq_rx) = unbounded_channel();
|
||||||
|
|
||||||
|
let plotter = config
|
||||||
|
.plotter
|
||||||
|
.as_ref()
|
||||||
|
.map(|plotter_config| Plotter::new(plotter_config.clone()));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
admin_mq_tx,
|
||||||
|
admin_mq_rx: Mutex::new(admin_mq_rx),
|
||||||
|
token,
|
||||||
|
plotter,
|
||||||
|
log_handlers: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(&self) {
|
||||||
|
loop {
|
||||||
|
if self.token.is_cancelled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match connect_tcp(&self.config.signal_cli_tcp_addr).await {
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"Could not connect to signal_cli @ ({}): {err}",
|
||||||
|
self.config.signal_cli_tcp_addr
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(err) = self.do_run(&client).await {
|
||||||
|
error!("Error with signal cli, reconnecting: {err}");
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_run(&self, signal_cli: &impl RpcClient) -> Result<(), RpcClientError> {
|
||||||
|
let mut admin_mq_rx = self
|
||||||
|
.admin_mq_rx
|
||||||
|
.try_lock()
|
||||||
|
.expect("Mutex should not be contended");
|
||||||
|
let mut signal_rx = signal_cli
|
||||||
|
.subscribe_receive(Some(self.config.signal_account.clone()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = self.token.cancelled() => {
|
||||||
|
info!("Stop requested");
|
||||||
|
return Ok(());
|
||||||
|
},
|
||||||
|
outbound_admin_msg = admin_mq_rx.recv() => {
|
||||||
|
if let Some(msg) = outbound_admin_msg {
|
||||||
|
// Log summary, or first 500 bytes of text if no summary provided
|
||||||
|
let summary = msg.summary.as_deref().unwrap_or_else(|| {
|
||||||
|
let len = msg.text.len().min(500);
|
||||||
|
&msg.text[..len]
|
||||||
|
});
|
||||||
|
info!("Sending alert: {summary}");
|
||||||
|
// Prepend origin line if present
|
||||||
|
let message = if let Some(origin) = &msg.origin {
|
||||||
|
format!("[{origin}]\n{}", msg.text)
|
||||||
|
} else {
|
||||||
|
msg.text
|
||||||
|
};
|
||||||
|
SignalMessage {
|
||||||
|
sender: self.config.signal_account.clone(),
|
||||||
|
recipient: self.config.admin_uuid.clone(),
|
||||||
|
message,
|
||||||
|
attachments: msg.attachment_paths,
|
||||||
|
}.send(signal_cli).await?;
|
||||||
|
} else {
|
||||||
|
warn!("admin_mq_rx is closed, halting service");
|
||||||
|
self.token.cancel();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signal_msg = signal_rx.next() => {
|
||||||
|
match signal_msg {
|
||||||
|
None => {
|
||||||
|
info!("Signal Rx: stream closed");
|
||||||
|
return Ok(());
|
||||||
|
},
|
||||||
|
Some(Err(err)) => {
|
||||||
|
error!("Signal Rx: {err}");
|
||||||
|
return Err(RpcClientError::ParseError(err));
|
||||||
|
}
|
||||||
|
Some(Ok(msg)) => {
|
||||||
|
//info!("Signal Rx: {msg:?}");
|
||||||
|
if msg.envelope.data_message.is_none() {
|
||||||
|
debug!("Ignoring message which was not a data message: {msg:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.config.admin_uuid.contains(&msg.envelope.source_uuid) {
|
||||||
|
warn!("Ignoring message from non-admin: {msg:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (resp, _) = join!(self.handle_signal_admin_message(&msg.envelope),
|
||||||
|
msg.envelope.send_read_receipt(signal_cli, &self.config.signal_account).map(|result| {
|
||||||
|
if let Err(err) = result {
|
||||||
|
warn!("Couldn't send read receipt: {err}");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let (message, attachments) = resp.unwrap_or_else(
|
||||||
|
|(code, msg)| {
|
||||||
|
let text = format!("{code}: {msg}");
|
||||||
|
error!("(cbmm) {text}");
|
||||||
|
(text, vec![])
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SignalMessage {
|
||||||
|
sender: self.config.signal_account.clone(),
|
||||||
|
recipient: vec![msg.envelope.source_uuid.clone()],
|
||||||
|
message,
|
||||||
|
attachments,
|
||||||
|
}.send(signal_cli).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns Err in case of a timeout
|
||||||
|
// Returns Ok when success or error text is generated
|
||||||
|
async fn handle_signal_admin_message(
|
||||||
|
&self,
|
||||||
|
msg: &Envelope,
|
||||||
|
) -> Result<(String, Vec<String>), (u16, Box<dyn Error>)> {
|
||||||
|
let data = msg.data_message.as_ref().unwrap();
|
||||||
|
|
||||||
|
// Admin messages starting with / are handled by gateway
|
||||||
|
// Other messages are forwarded to cbmm
|
||||||
|
if data.message.starts_with("/") {
|
||||||
|
// Parse the command using conf
|
||||||
|
let cmd = parse_gateway_command(&data.message).map_err(|err| (400, err.into()))?;
|
||||||
|
|
||||||
|
self.handle_gateway_command(cmd).await
|
||||||
|
} else {
|
||||||
|
// Connect to cbmm. Note that we could use a keep-alive strategy here maybe...
|
||||||
|
let mut cbmm_stream = timeout(
|
||||||
|
self.config.cbmm_timeout,
|
||||||
|
TcpStream::connect(&self.config.cbmm_tcp_addr),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(format_err("connecting", 504))?
|
||||||
|
.map_err(format_err("connecting", 502))?;
|
||||||
|
|
||||||
|
timeout(
|
||||||
|
self.config.cbmm_timeout,
|
||||||
|
cbmm_stream.write_all(data.message.as_bytes()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(format_err("writing", 504))?
|
||||||
|
.map_err(format_err("writing", 502))?;
|
||||||
|
|
||||||
|
let _ = cbmm_stream.shutdown().await;
|
||||||
|
|
||||||
|
// Wrap as BufReader so that we can use "read_until" which simplifies things
|
||||||
|
let mut reader = BufReader::new(cbmm_stream);
|
||||||
|
let mut buf = vec![];
|
||||||
|
timeout(self.config.cbmm_timeout, reader.read_until(b'\r', &mut buf))
|
||||||
|
.await
|
||||||
|
.map_err(format_err("reading", 504))?
|
||||||
|
.map_err(format_err("reading", 502))?;
|
||||||
|
|
||||||
|
let s = str::from_utf8(&buf).map_err(format_err("utf8", 502))?;
|
||||||
|
let text = s.trim().to_owned();
|
||||||
|
Ok((text, vec![]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler function that processes incoming http requests (push's from alertmanager expected)
|
||||||
|
pub async fn handle_http_request(
|
||||||
|
&self,
|
||||||
|
req: Request<Incoming>,
|
||||||
|
) -> Result<Response<String>, String> {
|
||||||
|
info!(
|
||||||
|
"Received http request: {} {} (version: {:?})",
|
||||||
|
req.method(),
|
||||||
|
req.uri().path(),
|
||||||
|
req.version()
|
||||||
|
);
|
||||||
|
|
||||||
|
fn ok_resp() -> Response<String> {
|
||||||
|
Response::new("OK".into())
|
||||||
|
}
|
||||||
|
fn err_resp(code: StatusCode, text: impl Into<String>) -> Response<String> {
|
||||||
|
let mut resp = Response::new(text.into());
|
||||||
|
*resp.status_mut() = code;
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
match (req.method(), req.uri().path()) {
|
||||||
|
(&Method::GET, "/") => Ok(ok_resp()),
|
||||||
|
(&Method::GET, "/health") => Ok(ok_resp()),
|
||||||
|
(&Method::POST, "/alert") => {
|
||||||
|
let v = req
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("When reading body bytes: {err}"))?
|
||||||
|
.to_bytes()
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
if let Err((code, msg)) = self.handle_post_alert(&v).await {
|
||||||
|
Ok(err_resp(code, msg))
|
||||||
|
} else {
|
||||||
|
Ok(ok_resp())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Ok(err_resp(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Not found '{} {}'", req.method(), req.uri().path()),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_gateway_command(
|
||||||
|
&self,
|
||||||
|
cmd: GatewayCommand,
|
||||||
|
) -> Result<(String, Vec<String>), (u16, Box<dyn Error>)> {
|
||||||
|
match cmd {
|
||||||
|
GatewayCommand::Log { filter } => {
|
||||||
|
let handlers = self.log_handlers.read().await;
|
||||||
|
if handlers.is_empty() {
|
||||||
|
return Ok(("No log sources registered yet".to_string(), vec![]));
|
||||||
|
}
|
||||||
|
let mut text = String::new();
|
||||||
|
for (origin, handler) in handlers.iter() {
|
||||||
|
// Apply filter if present
|
||||||
|
if let Some(ref f) = filter
|
||||||
|
&& !origin.matches_filter(f)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writeln!(&mut text, "=== [{origin}] ===").unwrap();
|
||||||
|
text.push_str(&handler.format_logs().await);
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
if text.is_empty() {
|
||||||
|
return Ok(("No matching log sources".to_string(), vec![]));
|
||||||
|
}
|
||||||
|
Ok((text, vec![]))
|
||||||
|
}
|
||||||
|
GatewayCommand::Query { query } => {
|
||||||
|
let plotter = self
|
||||||
|
.plotter
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
|
||||||
|
|
||||||
|
match plotter.oneoff_query(query).await {
|
||||||
|
Ok((
|
||||||
|
ExtractLabels {
|
||||||
|
name,
|
||||||
|
common_labels,
|
||||||
|
specific_labels,
|
||||||
|
},
|
||||||
|
ts,
|
||||||
|
)) => {
|
||||||
|
let mut text = format!("{name} {common_labels:?}\n");
|
||||||
|
|
||||||
|
for (mut sl, maybe_val) in specific_labels.into_iter().zip(ts.into_iter()) {
|
||||||
|
let name = sl.remove("__name__").unwrap_or_default();
|
||||||
|
let label = format!("{name} {sl:?}");
|
||||||
|
|
||||||
|
// TODO: Include timestamp?
|
||||||
|
let val = maybe_val
|
||||||
|
.map(|(_time, val)| val.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
if let Err(err) = writeln!(&mut text, "\t{label}\t\t{val}") {
|
||||||
|
return Err((500, err.into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((text, vec![]))
|
||||||
|
}
|
||||||
|
Err(err) => Err((500, err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GatewayCommand::Plot { query, duration } => {
|
||||||
|
let plotter = self
|
||||||
|
.plotter
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
|
||||||
|
|
||||||
|
plotter.purge_old_plots();
|
||||||
|
match plotter.create_oneoff_plot(query.clone(), duration).await {
|
||||||
|
Ok(filename) => Ok((query, vec![filename])),
|
||||||
|
Err(err) => Err((500, err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GatewayCommand::Series { matchers } => {
|
||||||
|
let plotter = self
|
||||||
|
.plotter
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
|
||||||
|
|
||||||
|
let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect();
|
||||||
|
match plotter.series(&matcher_refs).await {
|
||||||
|
Ok(data) => {
|
||||||
|
let mut text = data.iter().fold(String::default(), |mut buf, kv| {
|
||||||
|
writeln!(&mut buf, "{kv:?}").unwrap();
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
if text.is_empty() {
|
||||||
|
text = "no matches".into();
|
||||||
|
}
|
||||||
|
Ok((text, vec![]))
|
||||||
|
}
|
||||||
|
Err(err) => Err((500, err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GatewayCommand::Labels { matchers } => {
|
||||||
|
let plotter = self
|
||||||
|
.plotter
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
|
||||||
|
|
||||||
|
let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect();
|
||||||
|
match plotter.labels(&matcher_refs).await {
|
||||||
|
Ok(data) => {
|
||||||
|
let mut text = data.iter().fold(String::default(), |mut buf, l| {
|
||||||
|
writeln!(&mut buf, "{l}").unwrap();
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
if text.is_empty() {
|
||||||
|
text = "no matches".into();
|
||||||
|
}
|
||||||
|
Ok((text, vec![]))
|
||||||
|
}
|
||||||
|
Err(err) => Err((500, err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GatewayCommand::Alerts => {
|
||||||
|
let plotter = self
|
||||||
|
.plotter
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| (500, "prometheus was not configured".into()))?;
|
||||||
|
|
||||||
|
match plotter.alerts().await {
|
||||||
|
Ok(data) => {
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut text = data.iter().fold(String::default(), |mut buf, alert| {
|
||||||
|
let symbol = match alert.state {
|
||||||
|
AlertStatus::Pending => "🟡",
|
||||||
|
AlertStatus::Firing => "🔴",
|
||||||
|
AlertStatus::Resolved => "🟢",
|
||||||
|
};
|
||||||
|
let since = {
|
||||||
|
let dur = (now - alert.active_at).to_std().unwrap_or_default();
|
||||||
|
// reduce precision to at most seconds
|
||||||
|
let mut secs = dur.as_secs();
|
||||||
|
// if the duration is more than an hour, then reduce precision to minutes
|
||||||
|
if secs > 3600 {
|
||||||
|
secs -= secs % 60;
|
||||||
|
}
|
||||||
|
humantime::format_duration(Duration::new(secs, 0))
|
||||||
|
};
|
||||||
|
let annotations = &alert.annotations;
|
||||||
|
let labels = &alert.labels;
|
||||||
|
|
||||||
|
writeln!(&mut buf, "{symbol} {since} {annotations:?} {labels:?}")
|
||||||
|
.unwrap();
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
if text.is_empty() {
|
||||||
|
text = "no alerts".into();
|
||||||
|
}
|
||||||
|
Ok((text, vec![]))
|
||||||
|
}
|
||||||
|
Err(err) => Err((500, err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_post_alert(&self, body_bytes: &[u8]) -> Result<(), (StatusCode, &'static str)> {
|
||||||
|
let body_text = str::from_utf8(body_bytes).map_err(|err| {
|
||||||
|
warn!("When reading body bytes: {err}");
|
||||||
|
(StatusCode::BAD_REQUEST, "Request body was not utf-8")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let alert_msg: AlertMessage = serde_json::from_str(body_text).map_err(|err| {
|
||||||
|
error!("Could not parse json: {err}:\n{body_text}");
|
||||||
|
(StatusCode::BAD_REQUEST, "Invalid Json")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let text = self
|
||||||
|
.format_alert_text(&alert_msg)
|
||||||
|
.unwrap_or_else(|err| format!("error formatting alert text: {err}:\n{alert_msg:#?}"));
|
||||||
|
|
||||||
|
let mut attachment_paths = vec![];
|
||||||
|
if let Some(plotter) = self.plotter.as_ref() {
|
||||||
|
plotter.purge_old_plots();
|
||||||
|
for alert in alert_msg.alerts.iter() {
|
||||||
|
match plotter.create_alert_plot(alert).await {
|
||||||
|
Ok(path) => {
|
||||||
|
attachment_paths.push(path);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Could not format plot: {err} for {alert:#?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build summary: status sigils followed by alert names
|
||||||
|
let summary = alert_msg
|
||||||
|
.alerts
|
||||||
|
.iter()
|
||||||
|
.map(|alert| {
|
||||||
|
let symbol = match alert.status {
|
||||||
|
Status::Firing => "🔴",
|
||||||
|
Status::Resolved => "🟢",
|
||||||
|
};
|
||||||
|
let name = alert
|
||||||
|
.labels
|
||||||
|
.get("alertname")
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("?");
|
||||||
|
format!("{symbol}{name}")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
self.admin_mq_tx
|
||||||
|
.send(AdminMessage {
|
||||||
|
origin: None, // Prometheus alerts don't have a syslog origin
|
||||||
|
text,
|
||||||
|
attachment_paths,
|
||||||
|
summary: Some(summary),
|
||||||
|
})
|
||||||
|
.map_err(|_err| {
|
||||||
|
error!("Could not send alert message, queue is closed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Can't send signal msg right now, queue is closed",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_alert_text(&self, msg: &AlertMessage) -> Result<String, String> {
|
||||||
|
let mut text = "Alert:\n".to_owned();
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
for alert in msg.alerts.iter() {
|
||||||
|
let symbol = match alert.status {
|
||||||
|
Status::Firing => "🔴",
|
||||||
|
Status::Resolved => "🟢",
|
||||||
|
};
|
||||||
|
let since = {
|
||||||
|
let dur = (now - alert.starts_at).to_std().unwrap_or_default();
|
||||||
|
// reduce precision to at most seconds
|
||||||
|
let mut secs = dur.as_secs();
|
||||||
|
// if the duration is more than an hour, then reduce precision to minutes
|
||||||
|
if secs > 3600 {
|
||||||
|
secs -= secs % 60;
|
||||||
|
}
|
||||||
|
humantime::format_duration(Duration::new(secs, 0))
|
||||||
|
};
|
||||||
|
let name = alert
|
||||||
|
.annotations
|
||||||
|
.get("summary")
|
||||||
|
.or_else(|| alert.labels.get("alertname"))
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("?");
|
||||||
|
|
||||||
|
let expr = match alert.parse_expr_from_generator_url() {
|
||||||
|
Ok(expr) => expr,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"Couldn't parse generator url {}: {err}",
|
||||||
|
alert.generator_url
|
||||||
|
);
|
||||||
|
String::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
writeln!(&mut text, "{symbol}: ({since}) '{name}' {expr}")
|
||||||
|
.map_err(|err| format!("formatting error: {err}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_syslog_message(&self, syslog_msg: SyslogMessage) {
|
||||||
|
let origin = Origin::from(&syslog_msg);
|
||||||
|
|
||||||
|
// Try to get existing handler with read lock first
|
||||||
|
{
|
||||||
|
let handlers = self.log_handlers.read().await;
|
||||||
|
if let Some(handler) = handlers.get(&origin) {
|
||||||
|
handler.handle_syslog_message(syslog_msg, origin).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler doesn't exist, need to create one with write lock
|
||||||
|
let mut handlers = self.log_handlers.write().await;
|
||||||
|
// Double-check in case another task created it while we were waiting for the write lock
|
||||||
|
let handler = handlers.entry(origin.clone()).or_insert_with(|| {
|
||||||
|
info!("Creating new log handler for origin: {origin}");
|
||||||
|
LogHandler::new(self.config.log_handler.clone(), self.admin_mq_tx.clone())
|
||||||
|
});
|
||||||
|
handler.handle_syslog_message(syslog_msg, origin).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Gateway {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.token.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a lambda that expresses an error as a (u16, String) with given context info
|
||||||
|
fn format_err<E: std::fmt::Display>(
|
||||||
|
context: &'static str,
|
||||||
|
code: u16,
|
||||||
|
) -> impl Fn(E) -> (u16, Box<dyn Error>) {
|
||||||
|
move |err: E| -> (u16, Box<dyn Error>) { (code, format!("{context}: {err}").into()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_gateway_command() {
|
||||||
|
// Test log command without filter
|
||||||
|
let cmd = parse_gateway_command("/log").unwrap();
|
||||||
|
assert!(matches!(cmd, GatewayCommand::Log { filter: None }));
|
||||||
|
|
||||||
|
// Test log command with filter
|
||||||
|
let cmd = parse_gateway_command("/log myapp").unwrap();
|
||||||
|
if let GatewayCommand::Log { filter } = cmd {
|
||||||
|
assert_eq!(filter, Some("myapp".to_string()));
|
||||||
|
} else {
|
||||||
|
panic!("Expected Log command");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test uppercase log command
|
||||||
|
let cmd = parse_gateway_command("/LOG").unwrap();
|
||||||
|
assert!(matches!(cmd, GatewayCommand::Log { filter: None }));
|
||||||
|
|
||||||
|
// Test query command
|
||||||
|
let cmd = parse_gateway_command("/query test_metric").unwrap();
|
||||||
|
if let GatewayCommand::Query { query } = cmd {
|
||||||
|
assert_eq!(query, "test_metric");
|
||||||
|
} else {
|
||||||
|
panic!("Expected Query command");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test query command (uppercase)
|
||||||
|
let cmd = parse_gateway_command("/QUERY test_metric").unwrap();
|
||||||
|
if let GatewayCommand::Query { query } = cmd {
|
||||||
|
assert_eq!(query, "test_metric");
|
||||||
|
} else {
|
||||||
|
panic!("Expected Query command");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test plot command with default duration
|
||||||
|
let cmd = parse_gateway_command("/plot my_query").unwrap();
|
||||||
|
if let GatewayCommand::Plot { query, duration } = cmd {
|
||||||
|
assert_eq!(query, "my_query");
|
||||||
|
assert_eq!(duration, Duration::from_secs(60 * 60));
|
||||||
|
} else {
|
||||||
|
panic!("Expected Plot command");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test plot command with custom duration
|
||||||
|
let cmd = parse_gateway_command("/plot my_query -d 24h").unwrap();
|
||||||
|
if let GatewayCommand::Plot { query, duration } = cmd {
|
||||||
|
assert_eq!(query, "my_query");
|
||||||
|
assert_eq!(duration, Duration::from_secs(24 * 60 * 60));
|
||||||
|
} else {
|
||||||
|
panic!("Expected Plot command");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test series command with matchers
|
||||||
|
let cmd = parse_gateway_command("/series metric1 metric2").unwrap();
|
||||||
|
if let GatewayCommand::Series { matchers } = cmd {
|
||||||
|
assert_eq!(matchers, vec!["metric1", "metric2"]);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Series command");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test labels command
|
||||||
|
let cmd = parse_gateway_command("/labels foo bar").unwrap();
|
||||||
|
if let GatewayCommand::Labels { matchers } = cmd {
|
||||||
|
assert_eq!(matchers, vec!["foo", "bar"]);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Labels command");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test alerts command
|
||||||
|
let cmd = parse_gateway_command("/alerts").unwrap();
|
||||||
|
assert!(matches!(cmd, GatewayCommand::Alerts));
|
||||||
|
|
||||||
|
// Test alerts command (uppercase)
|
||||||
|
let cmd = parse_gateway_command("/ALERTS").unwrap();
|
||||||
|
assert!(matches!(cmd, GatewayCommand::Alerts));
|
||||||
|
|
||||||
|
// Test without leading slash
|
||||||
|
let cmd = parse_gateway_command("log").unwrap();
|
||||||
|
assert!(matches!(cmd, GatewayCommand::Log { filter: None }));
|
||||||
|
|
||||||
|
// Test empty command
|
||||||
|
assert!(parse_gateway_command("/").is_err());
|
||||||
|
assert!(parse_gateway_command("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_origin_matches_filter() {
|
||||||
|
let origin = Origin {
|
||||||
|
app: "muad-dib".to_string(),
|
||||||
|
host: "tokyo-server".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Without @: matches if app OR host contains the string
|
||||||
|
assert!(origin.matches_filter("muad"));
|
||||||
|
assert!(origin.matches_filter("dib"));
|
||||||
|
assert!(origin.matches_filter("tokyo"));
|
||||||
|
assert!(origin.matches_filter("server"));
|
||||||
|
assert!(!origin.matches_filter("paris"));
|
||||||
|
|
||||||
|
// With @: app must contain first part AND host must contain second part
|
||||||
|
assert!(origin.matches_filter("muad@tokyo"));
|
||||||
|
assert!(origin.matches_filter("dib@server"));
|
||||||
|
assert!(origin.matches_filter("muad-dib@tokyo-server"));
|
||||||
|
assert!(!origin.matches_filter("muad@paris"));
|
||||||
|
assert!(!origin.matches_filter("other@tokyo"));
|
||||||
|
|
||||||
|
// Empty parts with @
|
||||||
|
assert!(origin.matches_filter("@tokyo")); // empty app filter matches any app
|
||||||
|
assert!(origin.matches_filter("muad@")); // empty host filter matches any host
|
||||||
|
assert!(origin.matches_filter("@")); // both empty, matches everything
|
||||||
|
|
||||||
|
// Edge case: filter matches the @ in the format but origin has no @
|
||||||
|
let origin2 = Origin {
|
||||||
|
app: "app".to_string(),
|
||||||
|
host: "host".to_string(),
|
||||||
|
};
|
||||||
|
assert!(origin2.matches_filter("app@host"));
|
||||||
|
assert!(!origin2.matches_filter("app@other"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
str::FromStr,
|
||||||
|
sync::atomic::{AtomicI64, Ordering},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Represents a rate threshold, expressed as a string in the format:
|
||||||
|
///
|
||||||
|
/// * `1 / 10s`
|
||||||
|
/// * `2 / 5m`
|
||||||
|
/// * `3 / 1h`
|
||||||
|
/// * `> 1 / 10s`
|
||||||
|
/// * `>= 2 / 10s`
|
||||||
|
///
|
||||||
|
/// When the comparator is omitted, it is treated as `>=`
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
|
#[serde(try_from = "String")]
|
||||||
|
pub struct RateThreshold {
|
||||||
|
pub times: usize,
|
||||||
|
pub duration: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for RateThreshold {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let Some((first, second)) = s.trim().split_once('/') else {
|
||||||
|
return Err("missing '/' character in rate threshold".into());
|
||||||
|
};
|
||||||
|
|
||||||
|
let duration = conf_extra::parse_duration(second.trim())?;
|
||||||
|
|
||||||
|
let first = first.trim();
|
||||||
|
let maybe_mid = first.as_bytes().iter().position(|b| b.is_ascii_digit());
|
||||||
|
let (comparator, num) = if let Some(mid) = maybe_mid {
|
||||||
|
first.split_at(mid)
|
||||||
|
} else {
|
||||||
|
("", first)
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_greater_equal = match comparator.trim() {
|
||||||
|
">" => false,
|
||||||
|
">=" | "=>" | "" => true,
|
||||||
|
_ => return Err(format!("Unexpected comparator format: {comparator}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let num = num.trim();
|
||||||
|
let mut times: usize = num
|
||||||
|
.parse()
|
||||||
|
.map_err(|err| format!("invalid number {num}: {err}"))?;
|
||||||
|
|
||||||
|
if !is_greater_equal {
|
||||||
|
times += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if times == 0 {
|
||||||
|
return Err("Invalid threshold, times must be > 0".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RateThreshold { times, duration })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for RateThreshold {
|
||||||
|
type Error = <RateThreshold as FromStr>::Err;
|
||||||
|
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||||
|
RateThreshold::from_str(&s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rate limiter containing a single counter, and a minimum time window for the next event to pass
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SimpleRateLimiter {
|
||||||
|
last_timestamp: AtomicI64,
|
||||||
|
window: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl SimpleRateLimiter {
|
||||||
|
pub fn new(window: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
last_timestamp: Default::default(),
|
||||||
|
window: window.as_secs().try_into().unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a particular new timestamp passes the limit. This also updates the last-known timestamp.
|
||||||
|
pub fn evaluate(&self, ts_sec: i64) -> bool {
|
||||||
|
let last_ts = self.last_timestamp.load(Ordering::SeqCst);
|
||||||
|
let rate_limited = ts_sec - last_ts < self.window;
|
||||||
|
if !rate_limited && ts_sec > last_ts {
|
||||||
|
// If this is called concurrently, guarantee that we keep going
|
||||||
|
// until the max value is stored at self.last_timestamp,
|
||||||
|
// so self.last_timestamp is "eventually" only monotonically increasing.
|
||||||
|
store_max(ts_sec, &self.last_timestamp);
|
||||||
|
}
|
||||||
|
!rate_limited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn store_max(val: i64, at: &AtomicI64) {
|
||||||
|
let prev = at.swap(val, Ordering::SeqCst);
|
||||||
|
if prev > val {
|
||||||
|
store_max(prev, at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rate limiter that tracks alerts per source location (file:line).
|
||||||
|
///
|
||||||
|
/// This allows different error locations to alert independently, preventing one noisy
|
||||||
|
/// error from suppressing alerts from completely different code paths.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SourceLocationRateLimiter {
|
||||||
|
/// Maps (file, line) -> last alert timestamp
|
||||||
|
last_timestamps: HashMap<(String, String), i64>,
|
||||||
|
/// The rate limiting window in seconds
|
||||||
|
window: i64,
|
||||||
|
/// Maximum entries before triggering cleanup
|
||||||
|
max_entries: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceLocationRateLimiter {
|
||||||
|
pub fn new(window: Duration, max_entries: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
last_timestamps: HashMap::new(),
|
||||||
|
window: window.as_secs().try_into().unwrap(),
|
||||||
|
max_entries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an error from this source location should trigger an alert.
|
||||||
|
///
|
||||||
|
/// Returns true if the alert should fire (not rate-limited), false if suppressed.
|
||||||
|
/// Updates the stored timestamp if the alert fires.
|
||||||
|
pub fn evaluate(&mut self, file: &str, line: &str, ts_sec: i64) -> bool {
|
||||||
|
let key = (file.to_owned(), line.to_owned());
|
||||||
|
|
||||||
|
if let Some(&last_ts) = self.last_timestamps.get(&key)
|
||||||
|
&& ts_sec - last_ts < self.window
|
||||||
|
{
|
||||||
|
return false; // Rate limited
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert should fire - update timestamp
|
||||||
|
self.last_timestamps.insert(key, ts_sec);
|
||||||
|
|
||||||
|
// Clean up if we've exceeded max entries
|
||||||
|
if self.last_timestamps.len() > self.max_entries {
|
||||||
|
self.cleanup(ts_sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove entries older than the window
|
||||||
|
fn cleanup(&mut self, now: i64) {
|
||||||
|
self.last_timestamps
|
||||||
|
.retain(|_, &mut ts| now - ts < self.window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implements rate-limiting criteria such as 'at least n in the last w seconds'
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MultiRateLimiter {
|
||||||
|
/// Records the last n events
|
||||||
|
timestamps: Vec<i64>,
|
||||||
|
/// Invariant: Always points to the oldest of the last n timestamps in the buffer
|
||||||
|
idx: usize,
|
||||||
|
/// The length of the window (in seconds)
|
||||||
|
window: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultiRateLimiter {
|
||||||
|
pub fn new(num: usize, window: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
idx: 0,
|
||||||
|
timestamps: vec![Default::default(); num],
|
||||||
|
window: window.as_secs().try_into().unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a particular new timestamp passes the limit. This also updates the last-known timestamp.
|
||||||
|
///
|
||||||
|
/// Note: Assumes that new_timestamp is monotonically increasing, otherwise it might not work right.
|
||||||
|
pub fn evaluate(&mut self, new_timestamp: i64) -> bool {
|
||||||
|
let oldest = self.timestamps[self.idx];
|
||||||
|
if oldest >= new_timestamp {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.timestamps[self.idx] = new_timestamp;
|
||||||
|
self.idx += 1;
|
||||||
|
self.idx %= self.timestamps.len();
|
||||||
|
let next_oldest = self.timestamps[self.idx];
|
||||||
|
// If the next oldest is within 'window' of the new timestamp,
|
||||||
|
// then all of the most recent n are. Otherwise, at most n-1 of the most recent are.
|
||||||
|
next_oldest + self.window >= new_timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RateThreshold> for MultiRateLimiter {
|
||||||
|
fn from(src: RateThreshold) -> Self {
|
||||||
|
Self::new(src.times, src.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rate_threshold() {
|
||||||
|
let threshold = RateThreshold::from_str("1/10s").unwrap();
|
||||||
|
assert_eq!(threshold.times, 1);
|
||||||
|
assert_eq!(threshold.duration, Duration::from_secs(10));
|
||||||
|
let threshold = RateThreshold::from_str("1 / 10s").unwrap();
|
||||||
|
assert_eq!(threshold.times, 1);
|
||||||
|
assert_eq!(threshold.duration, Duration::from_secs(10));
|
||||||
|
|
||||||
|
let threshold = RateThreshold::from_str("2 / 5m").unwrap();
|
||||||
|
assert_eq!(threshold.times, 2);
|
||||||
|
assert_eq!(threshold.duration, Duration::from_secs(300));
|
||||||
|
|
||||||
|
let threshold = RateThreshold::from_str("> 3 / 10m").unwrap();
|
||||||
|
assert_eq!(threshold.times, 4);
|
||||||
|
assert_eq!(threshold.duration, Duration::from_secs(600));
|
||||||
|
|
||||||
|
let threshold = RateThreshold::from_str(">=3/10m").unwrap();
|
||||||
|
assert_eq!(threshold.times, 3);
|
||||||
|
assert_eq!(threshold.duration, Duration::from_secs(600));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_location_rate_limiter_basic() {
|
||||||
|
let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 100);
|
||||||
|
|
||||||
|
// First alert from location A should pass
|
||||||
|
assert!(limiter.evaluate("file_a.rs", "10", 1000));
|
||||||
|
|
||||||
|
// Second alert from same location within window should be rate limited
|
||||||
|
assert!(!limiter.evaluate("file_a.rs", "10", 1100));
|
||||||
|
|
||||||
|
// Alert from different location should pass (independent rate limiting)
|
||||||
|
assert!(limiter.evaluate("file_b.rs", "20", 1100));
|
||||||
|
|
||||||
|
// Same location after window passes should alert again
|
||||||
|
assert!(limiter.evaluate("file_a.rs", "10", 1700)); // 1000 + 600 + 100
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_location_rate_limiter_different_lines_same_file() {
|
||||||
|
let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 100);
|
||||||
|
|
||||||
|
// Different lines in same file should be independent
|
||||||
|
assert!(limiter.evaluate("file.rs", "10", 1000));
|
||||||
|
assert!(limiter.evaluate("file.rs", "20", 1000));
|
||||||
|
assert!(limiter.evaluate("file.rs", "30", 1000));
|
||||||
|
|
||||||
|
// Each should still be rate limited individually
|
||||||
|
assert!(!limiter.evaluate("file.rs", "10", 1100));
|
||||||
|
assert!(!limiter.evaluate("file.rs", "20", 1100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_location_rate_limiter_cleanup() {
|
||||||
|
// Use small max_entries to trigger cleanup
|
||||||
|
let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 3);
|
||||||
|
|
||||||
|
// Fill up the limiter
|
||||||
|
assert!(limiter.evaluate("file1.rs", "1", 1000));
|
||||||
|
assert!(limiter.evaluate("file2.rs", "2", 1000));
|
||||||
|
assert!(limiter.evaluate("file3.rs", "3", 1000));
|
||||||
|
assert_eq!(limiter.last_timestamps.len(), 3);
|
||||||
|
|
||||||
|
// Add one more, triggering cleanup - but all are fresh so none removed
|
||||||
|
assert!(limiter.evaluate("file4.rs", "4", 1000));
|
||||||
|
// Still have 4 after cleanup since none are old enough
|
||||||
|
assert_eq!(limiter.last_timestamps.len(), 4);
|
||||||
|
|
||||||
|
// Now add with a timestamp far in the future - old entries should be cleaned
|
||||||
|
assert!(limiter.evaluate("file5.rs", "5", 2000));
|
||||||
|
// Should have cleaned up entries from timestamp 1000 (older than 600 sec window)
|
||||||
|
assert_eq!(limiter.last_timestamps.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! Schema for the alertmanager http POST requests that are sent to us
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
pub type Timestamp = DateTime<Utc>;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Status {
|
||||||
|
Resolved,
|
||||||
|
Firing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AlertMessage {
|
||||||
|
// should be 4.0
|
||||||
|
pub version: String,
|
||||||
|
pub group_key: String,
|
||||||
|
pub status: Status,
|
||||||
|
pub receiver: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub group_labels: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub common_labels: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub common_annotations: BTreeMap<String, String>,
|
||||||
|
#[serde(alias = "externalURL")]
|
||||||
|
pub external_url: String,
|
||||||
|
pub alerts: Vec<Alert>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Alert {
|
||||||
|
pub status: Status,
|
||||||
|
#[serde(default)]
|
||||||
|
pub labels: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub annotations: BTreeMap<String, String>,
|
||||||
|
pub starts_at: Timestamp,
|
||||||
|
pub ends_at: Timestamp,
|
||||||
|
#[serde(alias = "generatorURL")]
|
||||||
|
pub generator_url: String,
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Alert {
|
||||||
|
pub fn parse_expr_from_generator_url(&self) -> Result<String, String> {
|
||||||
|
let url = Url::parse(&self.generator_url).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
for (k, v) in url.query_pairs() {
|
||||||
|
if k == "g0.expr" {
|
||||||
|
return Ok(v.into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("Couldn't find g0.expr".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_alert_message_parsing() {
|
||||||
|
let text = r#"{"receiver":"notify-chris","status":"firing","alerts":[{"status":"firing","labels":{"alertname":"Low tick success rate","instance":"172.31.5.8:9000","job":"ec2"},"annotations":{"summary":"Low tick success rate"},"startsAt":"2025-11-07T04:21:46.17Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9090/graph?g0.expr=rate%28tick_successes%5B5m%5D%29+%3C+0.9\u0026g0.tab=1","fingerprint":"543b6a7a3042ae2c"},{"status":"firing","labels":{"alertname":"Long tail tick times","instance":"172.31.5.8:9000","job":"ec2","quantile":"0.99"},"annotations":{"summary":"Long tail tick times"},"startsAt":"2025-11-07T04:50:01.17Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9090/graph?g0.expr=tick_time%7Bquantile%3D%220.99%22%7D+%3E+0.8\u0026g0.tab=1","fingerprint":"97130d38ef0ff0a4"}],"groupLabels":{},"commonLabels":{"instance":"172.31.5.8:9000","job":"ec2"},"commonAnnotations":{},"externalURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9093","version":"4","groupKey":"{}:{}","truncatedAlerts":0}"#;
|
||||||
|
|
||||||
|
let msg: AlertMessage = serde_json::from_str(text).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&msg.receiver, "notify-chris");
|
||||||
|
assert_eq!(msg.alerts.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
msg.alerts[0].annotations.get("summary").unwrap(),
|
||||||
|
"Low tick success rate"
|
||||||
|
);
|
||||||
|
|
||||||
|
let expr = msg.alerts[1].parse_expr_from_generator_url().unwrap();
|
||||||
|
assert_eq!(expr, r#"tick_time{quantile="0.99"} > 0.8"#);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! Display wrapper for formatting chrono::TimeDelta as "T-duration"
|
||||||
|
|
||||||
|
use chrono::TimeDelta;
|
||||||
|
use std::{
|
||||||
|
fmt::{self, Display},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A display wrapper that formats a chrono::TimeDelta as "T-duration" or "T+duration"
|
||||||
|
/// with coarse precision (truncated to minutes for durations > 1 hour).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct HumanTMinus(pub TimeDelta);
|
||||||
|
|
||||||
|
impl Display for HumanTMinus {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let (prefix, abs_duration) = if self.0.num_seconds() >= 0 {
|
||||||
|
("T-", self.0)
|
||||||
|
} else {
|
||||||
|
("T+", -self.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let std_dur = abs_duration.to_std().unwrap_or_default();
|
||||||
|
let mut secs = std_dur.as_secs();
|
||||||
|
|
||||||
|
// Reduce precision to minutes for durations > 1 hour
|
||||||
|
if secs > 3600 {
|
||||||
|
secs -= secs % 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
let coarse_dur = Duration::new(secs, 0);
|
||||||
|
// Remove spaces for compact format (e.g., "T-1m30s" not "T-1m 30s")
|
||||||
|
let formatted = humantime::format_duration(coarse_dur)
|
||||||
|
.to_string()
|
||||||
|
.replace(' ', "");
|
||||||
|
write!(f, "{}{}", prefix, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TimeDelta> for HumanTMinus {
|
||||||
|
fn from(td: TimeDelta) -> Self {
|
||||||
|
HumanTMinus(td)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_human_t_minus_positive() {
|
||||||
|
assert_eq!(HumanTMinus(TimeDelta::seconds(30)).to_string(), "T-30s");
|
||||||
|
assert_eq!(HumanTMinus(TimeDelta::seconds(90)).to_string(), "T-1m30s");
|
||||||
|
assert_eq!(HumanTMinus(TimeDelta::hours(1)).to_string(), "T-1h");
|
||||||
|
// > 1 hour: truncate to minutes
|
||||||
|
assert_eq!(HumanTMinus(TimeDelta::seconds(3700)).to_string(), "T-1h1m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_human_t_minus_negative() {
|
||||||
|
assert_eq!(HumanTMinus(TimeDelta::seconds(-30)).to_string(), "T+30s");
|
||||||
|
assert_eq!(HumanTMinus(TimeDelta::seconds(-3700)).to_string(), "T+1h1m");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! Logging initialization for signal-gateway
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub fn init_logging() {
|
||||||
|
// Install rustls crypto provider before any TLS connections are made.
|
||||||
|
// This is needed because we have both aws-lc-rs and ring in our dependency tree,
|
||||||
|
// and rustls 0.23 can't auto-detect which one to use when both are present.
|
||||||
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.expect("Failed to install rustls crypto provider");
|
||||||
|
|
||||||
|
if env::var("RUST_LOG").is_err() {
|
||||||
|
unsafe {
|
||||||
|
env::set_var("RUST_LOG", "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a default tracing subscriber, writing to STDERR
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// load dotenv file
|
||||||
|
match dotenvy::dotenv() {
|
||||||
|
Ok(path) => info!("Read dotenv file from: {}", path.display()),
|
||||||
|
Err(dotenvy::Error::Io(io_error)) => {
|
||||||
|
if matches!(io_error.kind(), std::io::ErrorKind::NotFound) {
|
||||||
|
info!("Couldn't find a dotenv file");
|
||||||
|
} else {
|
||||||
|
panic!("Io error when reading dot env file: {io_error}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
panic!("Error reading dotenv file: {err}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
|
||||||
|
#![allow(clippy::too_many_arguments)]
|
||||||
|
|
||||||
|
use jsonrpsee::async_client::ClientBuilder;
|
||||||
|
use jsonrpsee::core::client::SubscriptionClientT;
|
||||||
|
use jsonrpsee::proc_macros::rpc;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::net::ToSocketAddrs;
|
||||||
|
|
||||||
|
pub use jsonrpsee::core::ClientError as RpcClientError;
|
||||||
|
|
||||||
|
#[rpc(client)]
|
||||||
|
pub trait Rpc {
|
||||||
|
#[method(name = "addDevice", param_kind = map)]
|
||||||
|
async fn add_device(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
uri: String,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "addStickerPack", param_kind = map)]
|
||||||
|
async fn add_sticker_pack(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
uri: String,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "block", param_kind = map)]
|
||||||
|
fn block(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] groupIds: Vec<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "deleteLocalAccountData", param_kind = map)]
|
||||||
|
fn delete_local_account_data(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] ignoreRegistered: Option<bool>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "getAttachment", param_kind = map)]
|
||||||
|
fn get_attachment(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
id: String,
|
||||||
|
recipient: Option<String>,
|
||||||
|
#[allow(non_snake_case)] groupId: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "getAvatar", param_kind = map)]
|
||||||
|
fn get_avatar(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
contact: Option<String>,
|
||||||
|
profile: Option<String>,
|
||||||
|
#[allow(non_snake_case)] groupId: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "getSticker", param_kind = map)]
|
||||||
|
fn get_sticker(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] packId: String,
|
||||||
|
#[allow(non_snake_case)] stickerId: u32,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "getUserStatus", param_kind = map)]
|
||||||
|
fn get_user_status(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
usernames: Vec<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "joinGroup", param_kind = map)]
|
||||||
|
fn join_group(&self, account: Option<String>, uri: String) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[method(name = "finishChangeNumber", param_kind = map)]
|
||||||
|
fn finish_change_number(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
number: String,
|
||||||
|
verificationCode: String,
|
||||||
|
pin: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "finishLink", param_kind = map)]
|
||||||
|
fn finish_link(
|
||||||
|
&self,
|
||||||
|
#[allow(non_snake_case)] deviceLinkUri: String,
|
||||||
|
#[allow(non_snake_case)] deviceName: String,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "listAccounts", param_kind = map)]
|
||||||
|
fn list_accounts(&self) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "listContacts", param_kind = map)]
|
||||||
|
fn list_contacts(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] allRecipients: bool,
|
||||||
|
blocked: Option<bool>,
|
||||||
|
name: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "listDevices", param_kind = map)]
|
||||||
|
fn list_devices(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "listGroups", param_kind = map)]
|
||||||
|
fn list_groups(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] groupIds: Vec<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "listIdentities", param_kind = map)]
|
||||||
|
fn list_identities(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
number: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "listStickerPacks", param_kind = map)]
|
||||||
|
fn list_sticker_packs(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "quitGroup", param_kind = map)]
|
||||||
|
fn quit_group(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] groupId: String,
|
||||||
|
delete: bool,
|
||||||
|
admins: Vec<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "register", param_kind = map)]
|
||||||
|
fn register(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
voice: bool,
|
||||||
|
captcha: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "removeContact", param_kind = map)]
|
||||||
|
fn remove_contact(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipient: String,
|
||||||
|
forget: bool,
|
||||||
|
hide: bool,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "removeDevice", param_kind = map)]
|
||||||
|
fn remove_device(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] deviceId: u32,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "removePin", param_kind = map)]
|
||||||
|
fn remove_pin(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "remoteDelete", param_kind = map)]
|
||||||
|
fn remote_delete(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] targetTimestamp: u64,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] groupIds: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] noteToSelf: bool,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[method(name = "send", param_kind = map)]
|
||||||
|
fn send(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
groupIds: Vec<String>,
|
||||||
|
noteToSelf: bool,
|
||||||
|
endSession: bool,
|
||||||
|
message: String,
|
||||||
|
attachments: Vec<String>,
|
||||||
|
viewOnce: bool,
|
||||||
|
mentions: Vec<String>,
|
||||||
|
textStyle: Vec<String>,
|
||||||
|
quoteTimestamp: Option<u64>,
|
||||||
|
quoteAuthor: Option<String>,
|
||||||
|
quoteMessage: Option<String>,
|
||||||
|
quoteMention: Vec<String>,
|
||||||
|
quoteTextStyle: Vec<String>,
|
||||||
|
quoteAttachment: Vec<String>,
|
||||||
|
previewUrl: Option<String>,
|
||||||
|
previewTitle: Option<String>,
|
||||||
|
previewDescription: Option<String>,
|
||||||
|
previewImage: Option<String>,
|
||||||
|
sticker: Option<String>,
|
||||||
|
storyTimestamp: Option<u64>,
|
||||||
|
storyAuthor: Option<String>,
|
||||||
|
editTimestamp: Option<u64>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "sendContacts", param_kind = map)]
|
||||||
|
fn send_contacts(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "sendPaymentNotification", param_kind = map)]
|
||||||
|
fn send_payment_notification(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipient: String,
|
||||||
|
receipt: String,
|
||||||
|
note: String,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "sendReaction", param_kind = map)]
|
||||||
|
fn send_reaction(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] groupIds: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] noteToSelf: bool,
|
||||||
|
emoji: String,
|
||||||
|
#[allow(non_snake_case)] targetAuthor: String,
|
||||||
|
#[allow(non_snake_case)] targetTimestamp: u64,
|
||||||
|
remove: bool,
|
||||||
|
story: bool,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "sendReceipt", param_kind = map)]
|
||||||
|
fn send_receipt(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipient: String,
|
||||||
|
#[allow(non_snake_case)] targetTimestamps: Vec<u64>,
|
||||||
|
r#type: String,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "sendSyncRequest", param_kind = map)]
|
||||||
|
fn send_sync_request(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "sendTyping", param_kind = map)]
|
||||||
|
fn send_typing(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] groupIds: Vec<String>,
|
||||||
|
stop: bool,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "sendMessageRequestResponse", param_kind = map)]
|
||||||
|
fn send_message_request_response(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] groupIds: Vec<String>,
|
||||||
|
r#type: String,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "setPin", param_kind = map)]
|
||||||
|
fn set_pin(&self, account: Option<String>, pin: String) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "submitRateLimitChallenge", param_kind = map)]
|
||||||
|
fn submit_rate_limit_challenge(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
challenge: String,
|
||||||
|
captcha: String,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "startChangeNumber", param_kind = map)]
|
||||||
|
fn start_change_number(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
number: String,
|
||||||
|
voice: bool,
|
||||||
|
captcha: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "startLink", param_kind = map)]
|
||||||
|
fn start_link(&self, account: Option<String>) -> Result<JsonLink, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "trust", param_kind = map)]
|
||||||
|
fn trust(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipient: String,
|
||||||
|
#[allow(non_snake_case)] trustAllKnownKeys: bool,
|
||||||
|
#[allow(non_snake_case)] verifiedSafetyNumber: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "unblock", param_kind = map)]
|
||||||
|
fn unblock(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] groupIds: Vec<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "unregister", param_kind = map)]
|
||||||
|
fn unregister(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] deleteAccount: bool,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[method(name = "updateAccount", param_kind = map)]
|
||||||
|
fn update_account(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
deviceName: Option<String>,
|
||||||
|
unrestrictedUnidentifiedSender: Option<bool>,
|
||||||
|
discoverableByNumber: Option<bool>,
|
||||||
|
numberSharing: Option<bool>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "updateConfiguration", param_kind = map)]
|
||||||
|
fn update_configuration(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] readReceipts: Option<bool>,
|
||||||
|
#[allow(non_snake_case)] unidentifiedDeliveryIndicators: Option<bool>,
|
||||||
|
#[allow(non_snake_case)] typingIndicators: Option<bool>,
|
||||||
|
#[allow(non_snake_case)] linkPreviews: Option<bool>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "updateContact", param_kind = map)]
|
||||||
|
fn update_contact(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
recipient: String,
|
||||||
|
name: Option<String>,
|
||||||
|
expiration: Option<u32>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "updateGroup", param_kind = map)]
|
||||||
|
fn update_group(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] groupId: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
member: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] removeMember: Vec<String>,
|
||||||
|
admin: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] removeAdmin: Vec<String>,
|
||||||
|
ban: Vec<String>,
|
||||||
|
unban: Vec<String>,
|
||||||
|
#[allow(non_snake_case)] resetLink: bool,
|
||||||
|
#[allow(non_snake_case)] link: Option<String>,
|
||||||
|
#[allow(non_snake_case)] setPermissionAddMember: Option<String>,
|
||||||
|
#[allow(non_snake_case)] setPermissionEditDetails: Option<String>,
|
||||||
|
#[allow(non_snake_case)] setPermissionSendMessages: Option<String>,
|
||||||
|
expiration: Option<u32>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "updateProfile", param_kind = map)]
|
||||||
|
fn update_profile(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] givenName: Option<String>,
|
||||||
|
#[allow(non_snake_case)] familyName: Option<String>,
|
||||||
|
about: Option<String>,
|
||||||
|
#[allow(non_snake_case)] aboutEmoji: Option<String>,
|
||||||
|
#[allow(non_snake_case)] mobileCoinAddress: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
#[allow(non_snake_case)] removeAvatar: bool,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "uploadStickerPack", param_kind = map)]
|
||||||
|
fn upload_sticker_pack(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[method(name = "verify", param_kind = map)]
|
||||||
|
fn verify(
|
||||||
|
&self,
|
||||||
|
account: Option<String>,
|
||||||
|
#[allow(non_snake_case)] verificationCode: String,
|
||||||
|
pin: Option<String>,
|
||||||
|
) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
|
||||||
|
#[subscription(
|
||||||
|
name = "subscribeReceive" => "receive",
|
||||||
|
unsubscribe = "unsubscribeReceive",
|
||||||
|
item = RecvMessage,
|
||||||
|
param_kind = map
|
||||||
|
)]
|
||||||
|
async fn subscribe_receive(&self, account: Option<String>) -> SubscriptionResult;
|
||||||
|
|
||||||
|
#[method(name = "version")]
|
||||||
|
fn version(&self) -> Result<Value, ErrorObjectOwned>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct JsonLink {
|
||||||
|
pub device_link_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RecvMessage {
|
||||||
|
pub envelope: Envelope,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Envelope {
|
||||||
|
pub source: String,
|
||||||
|
pub source_number: String,
|
||||||
|
pub source_uuid: String,
|
||||||
|
pub source_name: String,
|
||||||
|
pub source_device: i64,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub data_message: Option<DataMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DataMessage {
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to signal-cli over tcp socket
|
||||||
|
pub async fn connect_tcp(
|
||||||
|
tcp: impl ToSocketAddrs,
|
||||||
|
) -> Result<impl SubscriptionClientT, std::io::Error> {
|
||||||
|
let (sender, receiver) = super::transports::tcp::connect(tcp).await?;
|
||||||
|
|
||||||
|
Ok(ClientBuilder::default().build_with_tokio(sender, receiver))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Envelope {
|
||||||
|
/// Send a read-receipt for an envelope
|
||||||
|
pub async fn send_read_receipt(
|
||||||
|
&self,
|
||||||
|
client: &impl RpcClient,
|
||||||
|
account: impl Into<String>,
|
||||||
|
) -> Result<(), RpcClientError> {
|
||||||
|
if let Some(dm) = self.data_message.as_ref() {
|
||||||
|
let _ = client
|
||||||
|
.send_receipt(
|
||||||
|
Some(account.into()),
|
||||||
|
self.source_uuid.clone(),
|
||||||
|
vec![dm.timestamp],
|
||||||
|
"read".into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper for invoking send, which has way too many parameters
|
||||||
|
pub struct SignalMessage {
|
||||||
|
pub sender: String,
|
||||||
|
pub recipient: Vec<String>,
|
||||||
|
pub message: String,
|
||||||
|
pub attachments: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalMessage {
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
pub async fn send(self, client: &impl RpcClient) -> Result<(), RpcClientError> {
|
||||||
|
// See note about string indexing here: https://github.com/AsamK/signal-cli/wiki/FAQ#string-indexing-units
|
||||||
|
let message_len_utf16: usize = self.message.chars().map(|c| c.len_utf16()).sum();
|
||||||
|
/*
|
||||||
|
account: Option<String>,
|
||||||
|
recipients: Vec<String>,
|
||||||
|
groupIds: Vec<String>,
|
||||||
|
noteToSelf: bool,
|
||||||
|
endSession: bool,
|
||||||
|
message: String,
|
||||||
|
attachments: Vec<String>,
|
||||||
|
viewOnce: bool,
|
||||||
|
mentions: Vec<String>,
|
||||||
|
textStyle: Vec<String>,
|
||||||
|
quoteTimestamp: Option<u64>,
|
||||||
|
quoteAuthor: Option<String>,
|
||||||
|
quoteMessage: Option<String>,
|
||||||
|
quoteMention: Vec<String>,
|
||||||
|
quoteTextStyle: Vec<String>,
|
||||||
|
quoteAttachment: Vec<String>,
|
||||||
|
previewUrl: Option<String>,
|
||||||
|
previewTitle: Option<String>,
|
||||||
|
previewDescription: Option<String>,
|
||||||
|
previewImage: Option<String>,
|
||||||
|
sticker: Option<String>,
|
||||||
|
storyTimestamp: Option<u64>,
|
||||||
|
storyAuthor: Option<String>,
|
||||||
|
editTimestamp: Option<u64>,
|
||||||
|
*/
|
||||||
|
let account = Some(self.sender);
|
||||||
|
let recipients = self.recipient;
|
||||||
|
let groupIds = vec![];
|
||||||
|
let noteToSelf = false;
|
||||||
|
let endSession = false;
|
||||||
|
let message = self.message;
|
||||||
|
let attachments = self.attachments;
|
||||||
|
let viewOnce = false;
|
||||||
|
let mentions = vec![];
|
||||||
|
let textStyle = vec![format!("0:{message_len_utf16}:MONOSPACE")];
|
||||||
|
let quoteTimestamp = None;
|
||||||
|
let quoteAuthor = None;
|
||||||
|
let quoteMention = vec![];
|
||||||
|
let quoteMessage = None;
|
||||||
|
let quoteTextStyle = vec![];
|
||||||
|
let quoteAttachment = vec![];
|
||||||
|
let previewUrl = None;
|
||||||
|
let previewTitle = None;
|
||||||
|
let previewDescription = None;
|
||||||
|
let previewImage = None;
|
||||||
|
let sticker = None;
|
||||||
|
let storyTimestamp = None;
|
||||||
|
let storyAuthor = None;
|
||||||
|
let editTimestamp = None;
|
||||||
|
|
||||||
|
let _resp = client
|
||||||
|
.send(
|
||||||
|
account,
|
||||||
|
recipients,
|
||||||
|
groupIds,
|
||||||
|
noteToSelf,
|
||||||
|
endSession,
|
||||||
|
message,
|
||||||
|
attachments,
|
||||||
|
viewOnce,
|
||||||
|
mentions,
|
||||||
|
textStyle,
|
||||||
|
quoteTimestamp,
|
||||||
|
quoteAuthor,
|
||||||
|
quoteMessage,
|
||||||
|
quoteMention,
|
||||||
|
quoteTextStyle,
|
||||||
|
quoteAttachment,
|
||||||
|
previewUrl,
|
||||||
|
previewTitle,
|
||||||
|
previewDescription,
|
||||||
|
previewImage,
|
||||||
|
sticker,
|
||||||
|
storyTimestamp,
|
||||||
|
storyAuthor,
|
||||||
|
editTimestamp,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
use conf::Conf;
|
||||||
|
use hyper::service::service_fn;
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
use hyper_util::server::conn::auto;
|
||||||
|
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||||
|
use syslog_rfc5424::SyslogMessage;
|
||||||
|
use tokio::net::{TcpListener, UdpSocket};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod gateway;
|
||||||
|
pub mod http;
|
||||||
|
mod human_duration;
|
||||||
|
mod init_logging;
|
||||||
|
pub mod jsonrpc;
|
||||||
|
pub mod plotter;
|
||||||
|
pub mod transports;
|
||||||
|
|
||||||
|
use config::Config;
|
||||||
|
use gateway::Gateway;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
init_logging::init_logging();
|
||||||
|
|
||||||
|
let config = Config::parse();
|
||||||
|
|
||||||
|
info!("Config = {config:#?}");
|
||||||
|
|
||||||
|
if config.dry_run {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = CancellationToken::new();
|
||||||
|
|
||||||
|
let gateway = Arc::new(Gateway::new(config.gateway, token.clone()).await);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(config.http_listen_addr).await.unwrap();
|
||||||
|
info!("Listening for http on {}", config.http_listen_addr);
|
||||||
|
|
||||||
|
let udp_socket = UdpSocket::bind(config.udp_listen_addr).await.unwrap();
|
||||||
|
info!("Listening for udp on {}", config.udp_listen_addr);
|
||||||
|
|
||||||
|
// Listen for ctrl-c
|
||||||
|
let thread_token = token.clone();
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.unwrap();
|
||||||
|
warn!("ctrl-c: Stop requested");
|
||||||
|
thread_token.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the two server tasks
|
||||||
|
let _http_task = start_http_task(listener, gateway.clone());
|
||||||
|
let _udp_task = start_udp_task(udp_socket, gateway.clone());
|
||||||
|
|
||||||
|
// Run gateway task and block on it returning. Note that it exits if the token is canceled.
|
||||||
|
gateway.run().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_http_task(listener: TcpListener, gateway: Arc<Gateway>) -> tokio::task::JoinHandle<()> {
|
||||||
|
// Loop waiting for http incoming connections, and pass them to gateway
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((stream, remote_addr)) = listener
|
||||||
|
.accept()
|
||||||
|
.await
|
||||||
|
.inspect_err(|err| error!("Error accepting connection: {err}"))
|
||||||
|
else {
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
info!("New connection from: {}", remote_addr);
|
||||||
|
|
||||||
|
// Spawn a new task to handle each connection
|
||||||
|
let thread_gateway = gateway.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let io = TokioIo::new(stream);
|
||||||
|
|
||||||
|
// Serve the connection using auto protocol detection (HTTP/1 or HTTP/2)
|
||||||
|
if let Err(err) = auto::Builder::new(hyper_util::rt::TokioExecutor::new())
|
||||||
|
.serve_connection(
|
||||||
|
io,
|
||||||
|
service_fn(|req| {
|
||||||
|
let thread_gateway = thread_gateway.clone();
|
||||||
|
async move { thread_gateway.handle_http_request(req).await }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Error serving connection: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_udp_task(udp_socket: UdpSocket, gateway: Arc<Gateway>) -> tokio::task::JoinHandle<()> {
|
||||||
|
// Loop waiting for http incoming connections, and pass them to gateway
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let Ok((len, _addr)) = udp_socket
|
||||||
|
.recv_from(&mut buf)
|
||||||
|
.await
|
||||||
|
.inspect_err(|err| error!("Error receiving UDP packet: {err}"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(text) = str::from_utf8(&buf[0..len])
|
||||||
|
.inspect_err(|err| error!("UDP packet was not utf8: {err}"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(msg) = SyslogMessage::from_str(text)
|
||||||
|
.inspect_err(|err| error!("UDP packet was not valid syslog: {err}:\n{text}"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.handle_syslog_message(msg).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_syslog_parsing() {
|
||||||
|
SyslogMessage::from_str(
|
||||||
|
"<12>1 2025-11-08T02:24:10.815221698+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
SyslogMessage::from_str(
|
||||||
|
"<12>1 2025-11-08T02:24:10.815221698+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
SyslogMessage::from_str(
|
||||||
|
"<12>1 2025-11-08T02:24:10.815+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports due to staleness"
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
use crate::http::Alert;
|
||||||
|
use chrono::Utc;
|
||||||
|
use conf::Conf;
|
||||||
|
use prom_client::{
|
||||||
|
AlertInfo, AlertsRequest, ExtractLabels, Labels, LabelsRequest, MetricTimeseries, MetricValue,
|
||||||
|
PromRequest, QueryRangeRequest, QueryRequest, SeriesRequest,
|
||||||
|
plot::{PlotStyle, PlotThreshold},
|
||||||
|
};
|
||||||
|
use rand::RngCore;
|
||||||
|
use reqwest::Client as ReqwestClient;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::{error::Error, str::FromStr, time::Duration};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
#[derive(Clone, Conf, Debug)]
|
||||||
|
pub struct PlotterConfig {
|
||||||
|
#[conf(long, env)]
|
||||||
|
pub prometheus_host: String,
|
||||||
|
#[conf(long, env, default_value = "30m", value_parser = conf_extra::parse_duration)]
|
||||||
|
pub plot_age_limit: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Plotter {
|
||||||
|
config: PlotterConfig,
|
||||||
|
plot_dir: String,
|
||||||
|
reqwest_client: ReqwestClient,
|
||||||
|
skip_labels: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plotter {
|
||||||
|
pub fn new(config: PlotterConfig) -> Self {
|
||||||
|
let plot_dir = "/tmp".into();
|
||||||
|
let reqwest_client = ReqwestClient::new();
|
||||||
|
let skip_labels = vec!["job".into(), "instance".into()];
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
plot_dir,
|
||||||
|
reqwest_client,
|
||||||
|
skip_labels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_plot(
|
||||||
|
&self,
|
||||||
|
matrix: &[MetricTimeseries],
|
||||||
|
threshold: Option<PlotThreshold>,
|
||||||
|
title: Option<&str>,
|
||||||
|
) -> Result<String, Box<dyn Error>> {
|
||||||
|
let filename = format!(
|
||||||
|
"{dir}/plot-{num}.png",
|
||||||
|
dir = self.plot_dir,
|
||||||
|
num = rand::rng().next_u64()
|
||||||
|
);
|
||||||
|
let mut plot_style = PlotStyle::default().dark_mode();
|
||||||
|
plot_style.skip_labels = self.skip_labels.clone();
|
||||||
|
if let Some(title) = title {
|
||||||
|
plot_style.title = Some(title.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
plot_style.plot_timeseries(&filename, matrix, threshold)?;
|
||||||
|
Ok(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn purge_old_plots(&self) {
|
||||||
|
for entry in WalkDir::new(&self.plot_dir)
|
||||||
|
.min_depth(1)
|
||||||
|
.max_depth(1)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
{
|
||||||
|
if !entry.file_type().is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(ext) = entry.path().extension() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if ext != "png" && ext != ".png" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path().display();
|
||||||
|
let Ok(metadata) = entry
|
||||||
|
.metadata()
|
||||||
|
.inspect_err(|err| warn!("Couldn't get metadata for {path}: {err}"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(time) = metadata
|
||||||
|
.created()
|
||||||
|
.inspect_err(|err| warn!("Couldn't get creation time for {path}: {err}"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(elapsed) = time
|
||||||
|
.elapsed()
|
||||||
|
.inspect_err(|err| warn!("Elapsed time calculation failed for {path}: {err}"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if elapsed > self.config.plot_age_limit
|
||||||
|
&& let Err(err) = std::fs::remove_file(entry.path())
|
||||||
|
{
|
||||||
|
warn!("Couldn't remove old png file {path}: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_alert_plot(&self, alert: &Alert) -> Result<String, Box<dyn Error>> {
|
||||||
|
let expr = alert.parse_expr_from_generator_url()?;
|
||||||
|
|
||||||
|
// Parse expressions like "query < 0.09" or "query < 0.09 and on (instance) up{...}"
|
||||||
|
// We look for comparison operators and extract the threshold
|
||||||
|
let (base_query, threshold) = parse_alert_expr(&expr)?;
|
||||||
|
|
||||||
|
// Build label selector from alert labels (excluding job/instance)
|
||||||
|
let label_selector = build_label_selector(&alert.labels, &self.skip_labels);
|
||||||
|
let query = if label_selector.is_empty() {
|
||||||
|
base_query.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{base_query}{{{label_selector}}}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let elapsed = now - alert.starts_at;
|
||||||
|
// Extend elapsed by 210%, but use at least plot_age_limit (default 30m)
|
||||||
|
let lengthen = elapsed
|
||||||
|
.checked_mul(31)
|
||||||
|
.and_then(|e| e.checked_div(10))
|
||||||
|
.ok_or("timedelta overflow")?;
|
||||||
|
let min_range = chrono::TimeDelta::from_std(self.config.plot_age_limit)?;
|
||||||
|
let range = lengthen.max(min_range);
|
||||||
|
|
||||||
|
info!("Prom range query: {query}");
|
||||||
|
let matrix = QueryRangeRequest::builder(query.clone())
|
||||||
|
.range(now - range..now)
|
||||||
|
.build()
|
||||||
|
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
|
||||||
|
.await?
|
||||||
|
.into_matrix()?;
|
||||||
|
|
||||||
|
self.create_plot(&matrix, Some(threshold), Some(&query))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_oneoff_plot(
|
||||||
|
&self,
|
||||||
|
query: String,
|
||||||
|
since: Duration,
|
||||||
|
) -> Result<String, Box<dyn Error>> {
|
||||||
|
info!("Prom range query: {query}");
|
||||||
|
let matrix = QueryRangeRequest::builder(query.to_owned())
|
||||||
|
.since(since)
|
||||||
|
.build()
|
||||||
|
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
|
||||||
|
.await?
|
||||||
|
.into_matrix()?;
|
||||||
|
|
||||||
|
self.create_plot(&matrix, None, Some(&query))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub async fn oneoff_query(
|
||||||
|
&self,
|
||||||
|
query: String,
|
||||||
|
) -> Result<(ExtractLabels, Vec<Option<(f64, Decimal)>>), Box<dyn Error>> {
|
||||||
|
info!("Prom query: {query}");
|
||||||
|
let vector: Vec<MetricValue> = QueryRequest { query, time: None }
|
||||||
|
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
|
||||||
|
.await?
|
||||||
|
.into_vector()?;
|
||||||
|
|
||||||
|
let labels = ExtractLabels::new(vector.iter().map(|mv| &mv.metric), &self.skip_labels);
|
||||||
|
let values = vector.into_iter().map(|mv| mv.value).collect();
|
||||||
|
|
||||||
|
Ok((labels, values))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn series(
|
||||||
|
&self,
|
||||||
|
matches: impl IntoIterator<Item: AsRef<str>>,
|
||||||
|
) -> Result<Vec<Labels>, Box<dyn Error>> {
|
||||||
|
Ok(SeriesRequest {
|
||||||
|
matches: matches.into_iter().map(|s| s.as_ref().to_owned()).collect(),
|
||||||
|
}
|
||||||
|
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn labels(
|
||||||
|
&self,
|
||||||
|
matches: impl IntoIterator<Item: AsRef<str>>,
|
||||||
|
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||||
|
Ok(LabelsRequest {
|
||||||
|
matches: matches.into_iter().map(|s| s.as_ref().to_owned()).collect(),
|
||||||
|
}
|
||||||
|
.send_with_client::<()>(&self.reqwest_client, &self.config.prometheus_host)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn alerts(&self) -> Result<Vec<AlertInfo>, Box<dyn Error>> {
|
||||||
|
Ok(AlertsRequest {}
|
||||||
|
.send_with_client(&self.reqwest_client, &self.config.prometheus_host)
|
||||||
|
.await?
|
||||||
|
.alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a prometheus label selector string from alert labels, excluding specified labels.
|
||||||
|
/// E.g., {"asset": "ETH", "job": "ec2"} with skip=["job"] -> `asset="ETH"`
|
||||||
|
fn build_label_selector(
|
||||||
|
labels: &std::collections::BTreeMap<String, String>,
|
||||||
|
skip: &[String],
|
||||||
|
) -> String {
|
||||||
|
labels
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| !skip.contains(k) && *k != "alertname")
|
||||||
|
.map(|(k, v)| format!("{k}=\"{v}\""))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an alert expression to extract the base query and threshold.
|
||||||
|
///
|
||||||
|
/// Handles expressions like:
|
||||||
|
/// - "query < 0.09"
|
||||||
|
/// - "query > 100"
|
||||||
|
/// - "rate(tick_successes[5m]) < 0.09 and on (instance) up{job=\"ec2\"}"
|
||||||
|
///
|
||||||
|
/// Returns (base_query, threshold) where base_query is the part before the comparator.
|
||||||
|
fn parse_alert_expr(expr: &str) -> Result<(String, PlotThreshold), Box<dyn std::error::Error>> {
|
||||||
|
// Look for comparison operators with surrounding spaces
|
||||||
|
for comparator in [" < ", " > "] {
|
||||||
|
if let Some(pos) = expr.find(comparator) {
|
||||||
|
let base_query = expr[..pos].to_owned();
|
||||||
|
let after_comparator = &expr[pos + comparator.len()..];
|
||||||
|
|
||||||
|
// The threshold is the first space-delimited token after the comparator
|
||||||
|
let numeric = after_comparator
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.ok_or("no numeric after comparator")?;
|
||||||
|
|
||||||
|
let limit = f64::from_str(numeric)?;
|
||||||
|
let threshold = if comparator == " < " {
|
||||||
|
PlotThreshold::LessThan(limit)
|
||||||
|
} else {
|
||||||
|
PlotThreshold::GreaterThan(limit)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok((base_query, threshold));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("no comparator (< or >) found in expression".into())
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
|
||||||
|
|
||||||
|
use futures_util::{Sink, SinkExt, Stream, stream::StreamExt};
|
||||||
|
use jsonrpsee::core::client::{ReceivedMessage, TransportReceiverT, TransportSenderT};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
mod stream_codec;
|
||||||
|
pub mod tcp;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
enum Errors {
|
||||||
|
#[error("Other: {0}")]
|
||||||
|
Other(String),
|
||||||
|
#[error("Closed")]
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sender<T: Send + Sink<String>> {
|
||||||
|
inner: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Send + Sink<String, Error = impl std::error::Error> + Unpin + 'static> TransportSenderT
|
||||||
|
for Sender<T>
|
||||||
|
{
|
||||||
|
type Error = Errors;
|
||||||
|
|
||||||
|
async fn send(&mut self, body: String) -> Result<(), Self::Error> {
|
||||||
|
self.inner
|
||||||
|
.send(body)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Errors::Other(format!("{e:?}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&mut self) -> Result<(), Self::Error> {
|
||||||
|
self.inner
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Errors::Other(format!("{e:?}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Receiver<T: Send + Stream> {
|
||||||
|
inner: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Send + Stream<Item = Result<String, std::io::Error>> + Unpin + 'static> TransportReceiverT
|
||||||
|
for Receiver<T>
|
||||||
|
{
|
||||||
|
type Error = Errors;
|
||||||
|
|
||||||
|
async fn receive(&mut self) -> Result<ReceivedMessage, Self::Error> {
|
||||||
|
match self.inner.next().await {
|
||||||
|
None => Err(Errors::Closed),
|
||||||
|
Some(Ok(msg)) => Ok(ReceivedMessage::Text(msg)),
|
||||||
|
Some(Err(e)) => Err(Errors::Other(format!("{e:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
|
||||||
|
|
||||||
|
use std::{io, str};
|
||||||
|
use tokio_util::bytes::BytesMut;
|
||||||
|
use tokio_util::codec::{Decoder, Encoder};
|
||||||
|
|
||||||
|
type Separator = u8;
|
||||||
|
|
||||||
|
/// Stream codec for streaming protocols (ipc, tcp)
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct StreamCodec {
|
||||||
|
incoming_separator: Separator,
|
||||||
|
outgoing_separator: Separator,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamCodec {
|
||||||
|
/// Default codec with streaming input data. Input can be both enveloped and not.
|
||||||
|
pub fn stream_incoming() -> Self {
|
||||||
|
StreamCodec::new(b'\n', b'\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// New custom stream codec
|
||||||
|
pub fn new(incoming_separator: Separator, outgoing_separator: Separator) -> Self {
|
||||||
|
StreamCodec {
|
||||||
|
incoming_separator,
|
||||||
|
outgoing_separator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decoder for StreamCodec {
|
||||||
|
type Item = String;
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn decode(&mut self, buf: &mut BytesMut) -> io::Result<Option<Self::Item>> {
|
||||||
|
if let Some(i) = buf
|
||||||
|
.as_ref()
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == self.incoming_separator)
|
||||||
|
{
|
||||||
|
let line = buf.split_to(i);
|
||||||
|
let _ = buf.split_to(1);
|
||||||
|
|
||||||
|
match str::from_utf8(line.as_ref()) {
|
||||||
|
Ok(s) => Ok(Some(s.to_string())),
|
||||||
|
Err(_) => Err(io::Error::other("invalid UTF-8")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encoder<String> for StreamCodec {
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
fn encode(&mut self, msg: String, buf: &mut BytesMut) -> io::Result<()> {
|
||||||
|
let mut payload = msg.into_bytes();
|
||||||
|
payload.push(self.outgoing_separator);
|
||||||
|
buf.extend_from_slice(&payload);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use std::io::Error;
|
||||||
|
|
||||||
|
use futures_util::stream::StreamExt;
|
||||||
|
use jsonrpsee::core::client::{TransportReceiverT, TransportSenderT};
|
||||||
|
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use tokio_util::codec::Decoder;
|
||||||
|
|
||||||
|
use super::stream_codec::StreamCodec;
|
||||||
|
use super::{Receiver, Sender};
|
||||||
|
|
||||||
|
/// Connect to a JSON-RPC TCP server.
|
||||||
|
pub async fn connect(
|
||||||
|
socket: impl ToSocketAddrs,
|
||||||
|
) -> Result<(impl TransportSenderT + Send, impl TransportReceiverT + Send), Error> {
|
||||||
|
let connection = TcpStream::connect(socket).await?;
|
||||||
|
let (sink, stream) = StreamCodec::stream_incoming().framed(connection).split();
|
||||||
|
|
||||||
|
let sender = Sender { inner: sink };
|
||||||
|
let receiver = Receiver { inner: stream };
|
||||||
|
|
||||||
|
Ok((sender, receiver))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user