use crate::messages::system::LogRecord;
use std::sync::{Arc, Mutex, OnceLock};
use tracing::field::{Field, Visit};
use tracing_subscriber::Layer;
const SELF_TARGET_PREFIX: &str = "mecha10_core::log_publisher";
struct PublishTarget {
node_id: String,
sender: tokio::sync::mpsc::UnboundedSender<LogRecord>,
}
type SharedSlot = Arc<Mutex<Option<PublishTarget>>>;
static SLOT: OnceLock<SharedSlot> = OnceLock::new();
fn shared_slot() -> SharedSlot {
SLOT.get_or_init(|| Arc::new(Mutex::new(None))).clone()
}
pub struct RedisLogLayer {
slot: SharedSlot,
}
impl RedisLogLayer {
pub fn new() -> Self {
Self { slot: shared_slot() }
}
#[cfg(test)]
fn with_slot(slot: SharedSlot) -> Self {
Self { slot }
}
}
impl Default for RedisLogLayer {
fn default() -> Self {
Self::new()
}
}
pub fn attach(node_id: &str, ctx: crate::Context) -> tokio::task::JoinHandle<()> {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let slot = shared_slot();
*slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(PublishTarget {
node_id: node_id.to_string(),
sender: tx,
});
spawn_log_publisher(ctx, rx)
}
#[derive(Default)]
struct MessageVisitor(String);
impl Visit for MessageVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{:?}", value);
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.0 = value.to_string();
}
}
}
impl<S> Layer<S> for RedisLogLayer
where
S: tracing::Subscriber,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
let metadata = event.metadata();
if metadata.target().starts_with(SELF_TARGET_PREFIX) {
return;
}
let guard = self.slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let Some(target) = guard.as_ref() else {
return;
};
let mut visitor = MessageVisitor::default();
event.record(&mut visitor);
let record = LogRecord {
node_id: target.node_id.clone(),
level: (*metadata.level()).into(),
target: metadata.target().to_string(),
message: visitor.0,
timestamp: crate::prelude::now_micros(),
};
let _ = target.sender.send(record);
}
}
#[allow(
clippy::print_stderr,
reason = "tracing is unsafe here by design (see doc comment above); eprintln! is the intentional escape hatch"
)]
fn spawn_log_publisher(
ctx: crate::Context,
mut receiver: tokio::sync::mpsc::UnboundedReceiver<LogRecord>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut consecutive_failures: u64 = 0;
while let Some(record) = receiver.recv().await {
match ctx.publish_raw("/system/log", &record).await {
Ok(()) => {
if consecutive_failures > 0 {
eprintln!(
"log_publisher: publishing to /system/log recovered after {consecutive_failures} failed record(s)"
);
consecutive_failures = 0;
}
}
Err(e) => {
consecutive_failures += 1;
if consecutive_failures == 1 {
eprintln!("log_publisher: failed to publish log record to /system/log: {e}");
}
}
}
}
if consecutive_failures > 0 {
eprintln!("log_publisher: shutting down with {consecutive_failures} unpublished record(s) still failing");
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::system::LogLevel;
#[test]
fn tracing_level_maps_to_log_level_in_increasing_severity_order() {
assert_eq!(LogLevel::from(tracing::Level::TRACE), LogLevel::Trace);
assert_eq!(LogLevel::from(tracing::Level::DEBUG), LogLevel::Debug);
assert_eq!(LogLevel::from(tracing::Level::INFO), LogLevel::Info);
assert_eq!(LogLevel::from(tracing::Level::WARN), LogLevel::Warn);
assert_eq!(LogLevel::from(tracing::Level::ERROR), LogLevel::Error);
}
#[tokio::test]
async fn publisher_task_forwards_records_from_the_channel_to_publish_raw() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<LogRecord>();
let record = LogRecord {
node_id: "motor-controller".to_string(),
level: LogLevel::Warn,
target: "mecha10_nodes_motor".to_string(),
message: "PWM saturation".to_string(),
timestamp: 1_700_000_000_000_000,
};
tx.send(record.clone()).unwrap();
drop(tx);
let received = rx.recv().await.expect("record should be forwarded");
assert_eq!(received.node_id, record.node_id);
assert_eq!(received.message, record.message);
assert!(rx.recv().await.is_none(), "channel should drain once sender is dropped");
}
fn layer_with_target(node_id: &str, sender: tokio::sync::mpsc::UnboundedSender<LogRecord>) -> RedisLogLayer {
let slot: SharedSlot = Arc::new(Mutex::new(Some(PublishTarget {
node_id: node_id.to_string(),
sender,
})));
RedisLogLayer::with_slot(slot)
}
#[test]
fn redis_log_layer_forwards_a_normal_event_tagged_with_the_attached_node_id() {
use tracing_subscriber::prelude::*;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<LogRecord>();
let subscriber = tracing_subscriber::registry().with(layer_with_target("motor-controller", tx));
tracing::subscriber::with_default(subscriber, || {
tracing::warn!(target: "mecha10_nodes_motor", "PWM saturation");
});
let forwarded = rx.try_recv().expect("the event should be forwarded");
assert_eq!(forwarded.node_id, "motor-controller");
assert_eq!(forwarded.target, "mecha10_nodes_motor");
assert_eq!(forwarded.message, "PWM saturation");
assert_eq!(forwarded.level, LogLevel::Warn);
}
#[test]
fn redis_log_layer_skips_events_targeting_itself() {
use tracing_subscriber::prelude::*;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<LogRecord>();
let subscriber = tracing_subscriber::registry().with(layer_with_target("motor-controller", tx));
tracing::subscriber::with_default(subscriber, || {
tracing::warn!(target: "mecha10_core::log_publisher", "should never be forwarded");
});
assert!(
rx.try_recv().is_err(),
"an event targeting this module itself must be dropped"
);
}
#[test]
fn redis_log_layer_drops_events_when_nothing_is_attached_yet() {
use tracing_subscriber::prelude::*;
let slot: SharedSlot = Arc::new(Mutex::new(None));
let subscriber = tracing_subscriber::registry().with(RedisLogLayer::with_slot(slot));
tracing::subscriber::with_default(subscriber, || {
tracing::info!("no publisher attached yet");
});
}
}