use std::io::{self, Write};
#[cfg(unix)]
const SOCKET_PATHS: [&str; 2] = ["/dev/log", "/var/run/syslog"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Facility {
#[default]
User,
Daemon,
Local0,
Local1,
Local2,
Local3,
Local4,
Local5,
Local6,
Local7,
}
impl Facility {
fn code(self) -> u8 {
match self {
Facility::User => 1,
Facility::Daemon => 3,
Facility::Local0 => 16,
Facility::Local1 => 17,
Facility::Local2 => 18,
Facility::Local3 => 19,
Facility::Local4 => 20,
Facility::Local5 => 21,
Facility::Local6 => 22,
Facility::Local7 => 23,
}
}
}
fn severity(level: &tracing::Level) -> u8 {
match *level {
tracing::Level::ERROR => 3,
tracing::Level::WARN => 4,
tracing::Level::INFO => 6,
tracing::Level::DEBUG | tracing::Level::TRACE => 7,
}
}
#[derive(Debug, Clone)]
pub struct SyslogConfig {
pub tag: String,
pub facility: Facility,
}
impl SyslogConfig {
pub fn new(tag: impl Into<String>) -> Self {
Self {
tag: tag.into(),
facility: Facility::default(),
}
}
pub fn with_facility(mut self, facility: Facility) -> Self {
self.facility = facility;
self
}
}
pub struct SyslogWriter {
#[cfg(unix)]
socket: Option<std::os::unix::net::UnixDatagram>,
tag: String,
facility: Facility,
level: tracing::Level,
}
impl SyslogWriter {
pub fn connect(config: &SyslogConfig, level: tracing::Level) -> Self {
#[cfg(unix)]
{
let socket = std::os::unix::net::UnixDatagram::unbound()
.ok()
.and_then(|s| {
SOCKET_PATHS
.iter()
.find_map(|path| s.connect(path).ok().map(|_| ()))
.map(|_| s)
});
Self {
socket,
tag: config.tag.clone(),
facility: config.facility,
level,
}
}
#[cfg(not(unix))]
{
Self {
tag: config.tag.clone(),
facility: config.facility,
level,
}
}
}
fn frame(&self, message: &str) -> String {
let priority = self.facility.code() * 8 + severity(&self.level);
format!(
"<{priority}>{}[{}]: {}",
self.tag,
std::process::id(),
message.trim_end()
)
}
}
impl Write for SyslogWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let message = String::from_utf8_lossy(buf);
if message.trim().is_empty() {
return Ok(buf.len());
}
#[cfg(unix)]
if let Some(socket) = &self.socket {
let _ = socket.send(self.frame(&message).as_bytes());
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn priority_combines_facility_and_severity() {
let config = SyslogConfig::new("svc").with_facility(Facility::Local3);
let writer = SyslogWriter::connect(&config, tracing::Level::ERROR);
let framed = writer.frame("boom");
assert!(framed.starts_with("<155>"), "got: {framed}");
}
#[test]
fn the_frame_carries_the_tag_and_pid() {
let config = SyslogConfig::new("nse-api");
let writer = SyslogWriter::connect(&config, tracing::Level::INFO);
let framed = writer.frame("hello");
assert!(framed.contains("nse-api["), "got: {framed}");
assert!(
framed.contains(&format!("[{}]", std::process::id())),
"got: {framed}"
);
assert!(framed.ends_with(": hello"), "got: {framed}");
}
#[test]
fn every_level_maps_to_a_syslog_severity() {
assert_eq!(severity(&tracing::Level::ERROR), 3);
assert_eq!(severity(&tracing::Level::WARN), 4);
assert_eq!(severity(&tracing::Level::INFO), 6);
assert_eq!(severity(&tracing::Level::DEBUG), 7);
assert_eq!(severity(&tracing::Level::TRACE), 7);
}
#[test]
fn a_missing_socket_is_not_an_error() {
let config = SyslogConfig::new("svc");
let mut writer = SyslogWriter::connect(&config, tracing::Level::INFO);
let result = writer.write(b"still fine\n");
assert!(result.is_ok());
assert_eq!(result.expect("ok"), 11);
}
#[test]
fn an_empty_line_is_not_sent() {
let config = SyslogConfig::new("svc");
let mut writer = SyslogWriter::connect(&config, tracing::Level::INFO);
let written = writer.write(b" \n").expect("ok");
assert_eq!(written, 4);
}
#[test]
fn the_default_facility_is_user() {
assert_eq!(Facility::default(), Facility::User);
assert_eq!(Facility::User.code(), 1);
}
}