Skip to main content

log_assist/
lib.rs

1//! log-assist: async-friendly Seq logger that auto-uses your Cargo project name.
2//!
3//! Usage:
4//! ```rust
5//! use log_assist::seq;
6//! #[tokio::main]
7//! async fn main() -> anyhow::Result<()> {
8//!     // Usually read these from your own config file once:
9//!     seq::init(seq::Config {
10//!         endpoint: "http://localhost:5341".into(), // Seq URL (no trailing slash)
11//!         api_key: None,                             // Some("YOUR_SEQ_API_KEY".into())
12//!         queue_capacity: 10_000,                    // optional tuning
13//!         flush_interval_ms: 1000,                   // batch flush cadence
14//!     }).await?;
15//!
16//!     seq::info("service started", serde_json::json!({"port": 8080})).await;
17//!     Ok(())
18//! }
19//! ```
20
21use once_cell::sync::OnceCell;
22use reqwest::Client;
23use serde::Serialize;
24use serde_json::json;
25use std::sync::Arc;
26use thiserror::Error;
27use tokio::sync::mpsc::{self, Sender};
28use tokio::time::{self, Duration};
29
30const APP_NAME: &str = env!("CARGO_PKG_VERSION"); // <-- pulled from your Cargo project name
31
32static STATE: OnceCell<Arc<SeqState>> = OnceCell::new();
33
34#[derive(Debug, Clone, Copy)]
35pub enum TimeMode {
36    Utc,
37    Local,
38}
39
40impl Default for TimeMode {
41    fn default() -> Self {
42        TimeMode::Utc
43    }
44}
45#[derive(Debug, Clone)]
46pub struct Config {
47    pub application_name: Option<String>, // optional override of app name
48    pub endpoint: String,        // e.g., http://seq.yourdomain:5341
49    pub api_key: Option<String>, // X-Seq-ApiKey
50    pub queue_capacity: usize,   // channel size
51    pub flush_interval_ms: u64,  // batching cadence
52    pub time_mode: TimeMode,     // NEW (Utc by default)
53    pub enable_panic_hook: bool, // default true
54}
55
56#[derive(Clone)]
57struct SeqState {
58    tx: Sender<Event>,
59    time_mode: TimeMode, // NEW
60    app_name: String,
61}
62
63#[derive(Debug, Error)]
64pub enum InitError {
65    #[error("already initialized")]
66    AlreadyInitialized,
67    #[error("http client error: {0}")]
68    Http(#[from] reqwest::Error),
69}
70
71#[derive(Debug, Error)]
72pub enum LogError {
73    #[error("logger not initialized")]
74    NotInitialized,
75    #[error("queue full, message dropped")]
76    QueueFull,
77}
78
79#[derive(Debug, Serialize)]
80struct Event {
81    #[serde(rename = "@t")]
82    timestamp: String, // RFC3339
83    #[serde(rename = "@mt")]
84    message_template: String,
85    #[serde(rename = "@l")]
86    level: String,
87    #[serde(rename = "Application")]
88    application: String,
89    #[serde(flatten)]
90    properties: serde_json::Value,
91}
92
93fn now_rfc3339(mode: TimeMode) -> String {
94    use ::time::format_description::well_known::Rfc3339;
95
96    match mode {
97        TimeMode::Utc => ::time::OffsetDateTime::now_utc()
98            .format(&Rfc3339)
99            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
100        TimeMode::Local => {
101            let dt = ::time::OffsetDateTime::now_local()
102                .unwrap_or_else(|_| ::time::OffsetDateTime::now_utc());
103            dt.format(&Rfc3339)
104                .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
105        }
106    }
107}
108
109/// Public module façade
110pub mod seq {
111
112    use super::*;
113
114    /// Initialize once (spawn background shipper task).
115    pub async fn init(cfg: Config) -> Result<(), InitError> {
116        if STATE.get().is_some() {
117            return Err(InitError::AlreadyInitialized);
118        }
119        if cfg.enable_panic_hook {
120            // pass url and api-key
121            install_panic_hook(cfg.clone());
122        }
123
124        let (tx, mut rx) = mpsc::channel::<Event>(cfg.queue_capacity);
125        let state = Arc::new(SeqState {
126            tx,
127            time_mode: cfg.time_mode,
128            app_name: cfg
129                .application_name
130                .clone()
131                .unwrap_or_else(|| APP_NAME.to_string()),
132        });
133        let endpoint = cfg.endpoint.trim_end_matches('/').to_string();
134        let api_key = cfg.api_key.clone();
135        let client = Client::builder().build()?;
136        let flush_every = Duration::from_millis(cfg.flush_interval_ms);
137
138        // background shipper
139        tokio::spawn(async move {
140            let mut buf: Vec<Event> = Vec::with_capacity(1024);
141            let mut ticker = time::interval(flush_every);
142
143            loop {
144                tokio::select! {
145                    maybe_ev = rx.recv() => {
146                        match maybe_ev {
147                            Some(ev) => { buf.push(ev); }
148                            None => { // channel closed => drain and exit
149                                let _ = try_flush(&client, &endpoint, api_key.as_deref(), &mut buf).await;
150                                break;
151                            }
152                        }
153                    }
154                    _ = ticker.tick() => {
155                        let _ = try_flush(&client, &endpoint, api_key.as_deref(), &mut buf).await;
156                    }
157                }
158            }
159        });
160
161        STATE.set(state).ok();
162        Ok(())
163    }
164
165    /// Info log (non-blocking enqueue; send happens in background).
166    pub async fn info(message: &str, props: serde_json::Value) {
167        let _ = enqueue("Information", message, props).await;
168    }
169
170    /// Warning log.
171    pub async fn warn(message: &str, props: serde_json::Value) {
172        let _ = enqueue("Warning", message, props).await;
173    }
174
175    /// Error log.
176    pub async fn error(message: &str, props: serde_json::Value) {
177        let _ = enqueue("Error", message, props).await;
178    }
179
180    /// Convenience when you don’t care about extra properties.
181    pub async fn info_msg(message: &str) {
182        info(message, json!({})).await
183    }
184    pub async fn warn_msg(message: &str) {
185        warn(message, json!({})).await
186    }
187    pub async fn error_msg(message: &str) {
188        error(message, json!({})).await
189    }
190
191    async fn enqueue(level: &str, message: &str, props: serde_json::Value) -> Result<(), LogError> {
192        let state = STATE.get().ok_or(LogError::NotInitialized)?.clone();
193        // let now = OffsetDateTime::now_utc().format(&::time::format_description::well_known::Rfc3339).unwrap();
194        let now = now_rfc3339(state.time_mode);
195
196        let mut properties = props;
197        // Always include app name; user props can override if they *really* want.
198        if !properties.get("App").is_some() {
199            if let Some(obj) = properties.as_object_mut() {
200                obj.insert("App".into(), json!(state.app_name));
201            }
202        }
203
204        let ev = Event {
205            timestamp: now,
206            message_template: message.to_string(),
207            level: level.to_string(),
208            application: state.app_name.to_string(),
209            properties,
210        };
211
212        state.tx.try_send(ev).map_err(|_| LogError::QueueFull)?;
213        Ok(())
214    }
215
216    async fn try_flush(
217        client: &Client,
218        endpoint: &str,
219        api_key: Option<&str>,
220        buf: &mut Vec<Event>,
221    ) -> Result<(), reqwest::Error> {
222        if buf.is_empty() {
223            return Ok(());
224        }
225
226        // Convert to CLEF lines (each event one line)
227        let clef_lines = buf
228            .iter()
229            .map(|e| serde_json::to_string(e).unwrap_or_default())
230            .collect::<Vec<_>>()
231            .join("\n");
232
233        let url = format!("{}/api/events/raw?clef", endpoint);
234        let mut req = client
235            .post(url)
236            .body(clef_lines)
237            .header("Content-Type", "application/vnd.serilog.clef");
238
239        if let Some(key) = api_key {
240            req = req.header("X-Seq-ApiKey", key);
241        }
242
243        let res = req.send().await?;
244        if res.status().is_success() {
245            buf.clear(); // only clear on success
246        } else {
247            eprintln!(
248                "SEQ response {}: {}",
249                res.status(),
250                res.text().await.unwrap_or_default()
251            );
252        }
253
254        Ok(())
255    }
256}
257use std::panic;
258
259/// Installs a global panic hook that logs to Seq and stderr.
260/// This will be automatically called by `seq::init()` after initialization.
261// ---------- Panic hook (blocking send; safe during runtime teardown) ----------
262fn install_panic_hook(config: Config) {
263    // Clone only the fields needed for the panic hook and move them into the closure
264    #[derive(Clone)]
265    struct PanicConfig {
266        endpoint: String,
267        api_key: Option<String>,
268    }
269    let panic_cfg = PanicConfig {
270        endpoint: config.endpoint.clone(),
271        api_key: config.api_key.clone(),
272    };
273
274    panic::set_hook(Box::new(move |panic_info| {
275        let location = panic_info
276            .location()
277            .map(|l| format!("{}:{}", l.file(), l.line()))
278            .unwrap_or_else(|| "unknown".into());
279
280        let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
281            s.to_string()
282        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
283            s.clone()
284        } else {
285            "Unknown panic payload".into()
286        };
287
288        eprintln!("💥 Panic at {location}: {msg}");
289
290        if let Some(state) = STATE.get() {
291            // Build a single CLEF line for the panic event
292            let ev = Event {
293                timestamp: now_rfc3339(state.time_mode),
294                message_template: "Panic occurred".to_string(),
295                level: "Error".to_string(),
296                application: state.app_name.to_string(),
297                properties: serde_json::json!({
298                    "location": location,
299                    "message": msg,
300                    "thread": std::thread::current().name(),
301                }),
302            };
303
304            send_event_sync(&panic_cfg.endpoint, panic_cfg.api_key.as_deref(), &ev);
305        }
306    }));
307}
308
309fn send_event_sync(endpoint: &str, api_key: Option<&str>, event: &Event) {
310    if let Ok(line) = serde_json::to_string(event) {
311        if let Ok(client) = reqwest::blocking::Client::builder()
312            .timeout(std::time::Duration::from_millis(800))
313            .build()
314        {
315            let mut req = client
316                .post(format!("{}/api/events/raw?clef", endpoint))
317                .header("Content-Type", "application/vnd.serilog.clef")
318                .body(line);
319            if let Some(key) = api_key {
320                req = req.header("X-Seq-ApiKey", key);
321            }
322            let _ = req.send();
323        }
324    }
325}