use tokio::sync::mpsc::{self, Sender};
const CHANNEL_CAPACITY: usize = 1024;
#[derive(Clone)]
pub struct AuditSink {
tx: Sender<(String, Vec<u8>)>,
}
impl AuditSink {
pub fn new(client: async_nats::Client) -> AuditSink {
let (tx, mut rx) = mpsc::channel::<(String, Vec<u8>)>(CHANNEL_CAPACITY);
tokio::spawn(async move {
while let Some((subject, bytes)) = rx.recv().await {
if let Err(e) = client.publish(subject, bytes.into()).await {
tracing::warn!(%e, "audit publish failed");
}
}
});
AuditSink { tx }
}
#[cfg(test)]
pub fn from_sender(tx: Sender<(String, Vec<u8>)>) -> AuditSink {
AuditSink { tx }
}
pub fn emit(&self, subject: String, envelope: &greentic_types::EventEnvelope) {
let bytes = match serde_json::to_vec(envelope) {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!(%e, "audit event serialization failed");
return;
}
};
if let Err(e) = self.tx.try_send((subject, bytes)) {
tracing::warn!(%e, "audit event dropped (channel full or closed)");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use greentic_types::{EnvId, EventId, TenantCtx, TenantId};
use serde_json::Value;
fn tenant_ctx() -> TenantCtx {
TenantCtx::new(
EnvId::try_from("prod").expect("valid env id"),
TenantId::try_from("t1").expect("valid tenant id"),
)
}
fn sample_envelope() -> greentic_types::EventEnvelope {
greentic_types::EventEnvelope {
id: EventId::new("id1").expect("valid event id"),
topic: "runner.flow".to_string(),
r#type: "greentic.runner.flow.node_end".to_string(),
source: "runner".to_string(),
tenant: tenant_ctx(),
subject: Some("flow:f1/node:n1".to_string()),
time: chrono::Utc::now(),
correlation_id: Some("s1".to_string()),
payload: serde_json::json!({"node_id": "n1"}),
metadata: Default::default(),
}
}
#[tokio::test]
async fn emit_drops_without_blocking_when_channel_is_saturated() {
let (tx, mut _rx) = mpsc::channel::<(String, Vec<u8>)>(CHANNEL_CAPACITY);
let sink = AuditSink::from_sender(tx);
let envelope = sample_envelope();
for _ in 0..CHANNEL_CAPACITY {
sink.emit("audit.t1.flow.node_end".to_string(), &envelope);
}
sink.emit("audit.t1.flow.node_end".to_string(), &envelope);
drop(_rx);
}
#[tokio::test]
async fn emit_serializes_envelope_with_type_field() {
let (tx, mut rx) = mpsc::channel::<(String, Vec<u8>)>(CHANNEL_CAPACITY);
let sink = AuditSink::from_sender(tx);
let envelope = sample_envelope();
sink.emit("audit.t1.flow.node_end".to_string(), &envelope);
let (subject, bytes) = rx.recv().await.expect("event enqueued");
assert_eq!(subject, "audit.t1.flow.node_end");
let value: Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(
value.get("type").and_then(Value::as_str),
Some("greentic.runner.flow.node_end")
);
}
}