use std::borrow::Cow;
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use flowscope::Timestamp;
use crate::anomaly::Severity;
use crate::anomaly::sink::AnomalySink;
pub struct StdoutSink {
buf: Vec<u8>,
}
impl StdoutSink {
pub fn with_capacity(cap: usize) -> Self {
Self {
buf: Vec::with_capacity(cap),
}
}
}
impl Default for StdoutSink {
fn default() -> Self {
Self::with_capacity(4096)
}
}
impl AnomalySink for StdoutSink {
fn write(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
key: Option<&dyn crate::anomaly::Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) {
self.buf.clear();
let _ = write!(&mut self.buf, "[{severity}] {kind} ts={ts}");
if let Some(k) = key {
let _ = write!(&mut self.buf, " key={k:?}");
}
for (l, v) in observations {
let _ = write!(&mut self.buf, " {l}={v}");
}
for (l, v) in metrics {
let _ = write!(&mut self.buf, " {l}={v:.2}");
}
let _ = writeln!(&mut self.buf);
let _ = std::io::stdout().write_all(&self.buf);
}
fn flush(&mut self) -> Result<(), std::io::Error> {
std::io::stdout().flush()
}
}
#[cfg(feature = "serde")]
pub struct StdoutJsonSink {
buf: Vec<u8>,
}
#[cfg(feature = "serde")]
impl StdoutJsonSink {
pub fn with_capacity(cap: usize) -> Self {
Self {
buf: Vec::with_capacity(cap),
}
}
}
#[cfg(feature = "serde")]
impl Default for StdoutJsonSink {
fn default() -> Self {
Self::with_capacity(4096)
}
}
#[cfg(feature = "serde")]
impl AnomalySink for StdoutJsonSink {
fn write(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
key: Option<&dyn crate::anomaly::Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) {
use serde_json::{Map, Value};
self.buf.clear();
let mut obj = Map::with_capacity(6);
obj.insert("severity".into(), severity.to_string().into());
obj.insert("kind".into(), kind.into());
obj.insert("ts_sec".into(), ts.sec.into());
obj.insert("ts_nsec".into(), ts.nsec.into());
if let Some(k) = key {
obj.insert("key".into(), format!("{k:?}").into());
}
if !observations.is_empty() {
let mut obs_map = Map::with_capacity(observations.len());
for (l, v) in observations {
obs_map.insert((*l).to_string(), v.as_ref().into());
}
obj.insert("observations".into(), obs_map.into());
}
if !metrics.is_empty() {
let mut met_map = Map::with_capacity(metrics.len());
for (l, v) in metrics {
met_map.insert(
(*l).to_string(),
if v.is_finite() {
Value::from(*v)
} else {
Value::Null
},
);
}
obj.insert("metrics".into(), met_map.into());
}
let _ = serde_json::to_writer(&mut self.buf, &obj);
self.buf.push(b'\n');
let _ = std::io::stdout().write_all(&self.buf);
}
fn flush(&mut self) -> Result<(), std::io::Error> {
std::io::stdout().flush()
}
}
pub struct TracingSink {
msg_buf: String,
}
impl TracingSink {
pub fn with_capacity(cap: usize) -> Self {
Self {
msg_buf: String::with_capacity(cap),
}
}
}
impl Default for TracingSink {
fn default() -> Self {
Self::with_capacity(512)
}
}
impl AnomalySink for TracingSink {
fn write(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
key: Option<&dyn crate::anomaly::Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) {
use std::fmt::Write as _;
self.msg_buf.clear();
if let Some(k) = key {
let _ = write!(&mut self.msg_buf, "key={k:?}");
}
for (l, v) in observations {
if !self.msg_buf.is_empty() {
self.msg_buf.push(' ');
}
let _ = write!(&mut self.msg_buf, "{l}={v}");
}
for (l, v) in metrics {
if !self.msg_buf.is_empty() {
self.msg_buf.push(' ');
}
let _ = write!(&mut self.msg_buf, "{l}={v:.2}");
}
match severity {
Severity::Info => {
tracing::info!(target: "netring::anomaly", kind, ts_sec = ts.sec, ts_nsec = ts.nsec, "{}", self.msg_buf)
}
Severity::Warning => {
tracing::warn!(target: "netring::anomaly", kind, ts_sec = ts.sec, ts_nsec = ts.nsec, "{}", self.msg_buf)
}
Severity::Error | Severity::Critical => {
tracing::error!(target: "netring::anomaly", kind, severity = %severity, ts_sec = ts.sec, ts_nsec = ts.nsec, "{}", self.msg_buf)
}
}
}
}
pub struct ChannelSink {
tx: ChannelTx,
}
enum ChannelTx {
Unbounded(tokio::sync::mpsc::UnboundedSender<flowscope::OwnedAnomaly>),
Bounded {
tx: tokio::sync::mpsc::Sender<flowscope::OwnedAnomaly>,
dropped: Arc<AtomicU64>,
},
}
impl ChannelSink {
pub fn new(tx: tokio::sync::mpsc::UnboundedSender<flowscope::OwnedAnomaly>) -> Self {
Self {
tx: ChannelTx::Unbounded(tx),
}
}
pub fn channel() -> (
Self,
tokio::sync::mpsc::UnboundedReceiver<flowscope::OwnedAnomaly>,
) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Self::new(tx), rx)
}
pub fn bounded(
capacity: usize,
) -> (
Self,
tokio::sync::mpsc::Receiver<flowscope::OwnedAnomaly>,
Arc<AtomicU64>,
) {
let (tx, rx) = tokio::sync::mpsc::channel(capacity);
let dropped = Arc::new(AtomicU64::new(0));
(
Self {
tx: ChannelTx::Bounded {
tx,
dropped: Arc::clone(&dropped),
},
},
rx,
dropped,
)
}
}
fn build_owned(
kind: &'static str,
severity: Severity,
ts: Timestamp,
key: Option<&dyn crate::anomaly::Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) -> flowscope::OwnedAnomaly {
let mut owned = flowscope::OwnedAnomaly::new(
crate::anomaly::sink::detector_kind_for(kind),
severity.into(),
ts,
);
if let Some(k) = key {
if let Some(fkey) = k
.as_any()
.downcast_ref::<flowscope::extract::FiveTupleKey>()
{
owned = owned.with_key(fkey);
}
}
for (label, value) in observations {
owned = owned.with_observation(label, value.to_string());
}
for (label, value) in metrics {
owned = owned.with_metric(label, *value);
}
owned
}
impl AnomalySink for ChannelSink {
fn write(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
key: Option<&dyn crate::anomaly::Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) {
let owned = build_owned(kind, severity, ts, key, observations, metrics);
match &self.tx {
ChannelTx::Unbounded(tx) => {
let _ = tx.send(owned);
}
ChannelTx::Bounded { tx, dropped } => {
if tx.try_send(owned).is_err() {
dropped.fetch_add(1, Ordering::Relaxed);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::anomaly::sink::{AnomalySink, AnomalySinkExt};
#[test]
fn bounded_channel_sink_drops_with_count_when_full() {
let (mut sink, _rx, dropped) = ChannelSink::bounded(2);
for _ in 0..5 {
sink.write(
"Test",
Severity::Warning,
Timestamp::new(0, 0),
None,
&[],
&[],
);
}
assert_eq!(dropped.load(Ordering::Relaxed), 3);
}
#[test]
fn bounded_channel_sink_delivers_until_full() {
let (mut sink, mut rx, dropped) = ChannelSink::bounded(4);
for _ in 0..3 {
sink.write("T", Severity::Info, Timestamp::new(0, 0), None, &[], &[]);
}
assert_eq!(dropped.load(Ordering::Relaxed), 0);
let mut got = 0;
while rx.try_recv().is_ok() {
got += 1;
}
assert_eq!(got, 3);
}
#[test]
fn stdout_sink_default_uses_4kib_buffer() {
let s = StdoutSink::default();
assert!(s.buf.capacity() >= 4096);
}
#[test]
fn stdout_sink_emits_and_reuses_buffer() {
let mut s = StdoutSink::with_capacity(256);
let initial_cap = s.buf.capacity();
s.begin("Test", Severity::Info, Timestamp::new(0, 0))
.with("note", "hi")
.emit();
s.begin("Again", Severity::Info, Timestamp::new(0, 0))
.with("note", "hi")
.emit();
assert_eq!(
s.buf.capacity(),
initial_cap,
"small anomaly must not grow the buffer past its prepared cap"
);
}
#[test]
fn tracing_sink_default_uses_512b_buffer() {
let s = TracingSink::default();
assert!(s.msg_buf.capacity() >= 512);
}
#[test]
fn tracing_sink_emits_without_panic() {
let mut s = TracingSink::default();
s.begin("T", Severity::Warning, Timestamp::new(0, 0))
.with("note", "hi")
.with_metric("n", 1.0)
.emit();
}
#[tokio::test(flavor = "current_thread")]
async fn channel_sink_forwards_owned_anomaly() {
let (mut sink, mut rx) = ChannelSink::channel();
sink.begin("Forwarded", Severity::Critical, Timestamp::new(1, 2))
.with("a", "x")
.with_metric("b", 3.0)
.emit();
let received = rx.recv().await.expect("channel did not deliver");
assert_eq!(received.kind.as_str(), "Forwarded");
assert_eq!(received.severity, flowscope::event::Severity::Critical);
assert_eq!(received.observations[0].0, "a");
assert_eq!(received.observations[0].1.as_ref(), "x");
assert_eq!(received.metrics[0], ("b", 3.0));
}
#[cfg(feature = "serde")]
#[test]
fn stdout_json_sink_emits_valid_json() {
let mut s = StdoutJsonSink::with_capacity(512);
s.begin("JsonKind", Severity::Info, Timestamp::new(1, 2))
.with("note", "value")
.with_metric("count", 7.5)
.emit();
let s_str = std::str::from_utf8(&s.buf).expect("UTF-8 JSON bytes");
let payload = s_str.trim_end_matches('\n');
let v: serde_json::Value = serde_json::from_str(payload).expect("valid JSON");
assert_eq!(v["kind"], "JsonKind");
assert_eq!(v["severity"], "info");
}
}