Skip to main content

relastic/
log.rs

1use chrono::DateTime;
2use chrono::Datelike;
3use chrono::Local;
4use chrono::SecondsFormat;
5use chrono::TimeZone;
6use chrono::Utc;
7use lazy_static::lazy_static;
8use serde::Deserialize;
9use serde::Serialize;
10use serde_json::json;
11use snafu::Snafu;
12use std::collections::HashMap;
13use std::string::ToString;
14use std::sync::RwLock;
15use std::sync::atomic::AtomicBool;
16use std::sync::mpsc;
17use std::thread;
18use std::time::Duration;
19use strum_macros::Display;
20use strum_macros::EnumString;
21
22lazy_static! {
23    static ref LOG: RwLock<Option<std::sync::mpsc::SyncSender<LogMessage>>> = RwLock::new(None);
24}
25lazy_static! {
26    static ref SHOULD_LOOP: AtomicBool = AtomicBool::new(false);
27}
28lazy_static! {
29    static ref IS_FLUSHED: AtomicBool = AtomicBool::new(false);
30}
31lazy_static! {
32    static ref LOG_TO_CONSOLE: AtomicBool = AtomicBool::new(false);
33}
34lazy_static! {
35    static ref LOG_TO_ELASTIC: AtomicBool = AtomicBool::new(false);
36}
37
38static WAIT: Duration = Duration::from_secs(1);
39
40#[derive(Debug, Snafu)]
41pub enum Error {
42    #[snafu(display("Request failed: {}", inner))]
43    RequestFailed { inner: reqwest::Error },
44    #[snafu(display("Failed to deserialize log API reply: {}", inner))]
45    DeserializationFailed { inner: reqwest::Error },
46    #[snafu(display(
47        "Some, or all chunked logs, were not accepted by the log API: {}",
48        errors
49    ))]
50    SomeLogsWereNotAccepted { errors: String },
51    #[snafu(display("The log API rejected the whole chunked log request: {}", errors))]
52    ApiRejectedLogPayload { errors: String },
53}
54
55#[derive(Display, Debug, PartialEq, Eq, EnumString)]
56pub enum LogLevel {
57    #[strum(to_string = "DBG")]
58    Debug,
59    #[strum(to_string = "INF")]
60    Information,
61    #[strum(to_string = "WRN")]
62    Warning,
63    #[strum(to_string = "ERR")]
64    Error,
65    #[strum(to_string = "FTL")]
66    Fatal,
67}
68
69#[derive(Serialize, Deserialize, Display, PartialEq, Eq, Hash, EnumString, Clone)]
70pub enum LogEnvironment {
71    Production,
72    Development,
73}
74
75pub struct LogMessage {
76    level: LogLevel,
77    message_template: String,
78    message: String,
79    fields: HashMap<String, String>,
80}
81
82#[derive(Serialize)]
83struct LogInnerIndex {
84    #[serde(rename(serialize = "_index"))]
85    index: String,
86}
87
88#[derive(Serialize)]
89struct LogIndex {
90    index: LogInnerIndex,
91}
92
93#[derive(Deserialize, Serialize, Debug)]
94struct ElasticErrorCause {
95    r#type: String,
96    reason: String,
97}
98
99#[derive(Deserialize, Serialize, Debug)]
100struct ElasticError {
101    caused_by: ElasticErrorCause,
102}
103
104#[derive(Serialize, Deserialize)]
105struct ElasticSingleLogStatus {
106    #[serde(rename(deserialize = "_index"))]
107    index: String,
108    status: u16,
109    #[serde(rename(deserialize = "_id"))]
110    id: String,
111    error: Option<ElasticError>,
112}
113
114#[derive(serde::Deserialize, Serialize)]
115struct ElasticSingleReplyIndex {
116    index: ElasticSingleLogStatus,
117}
118
119#[derive(serde::Deserialize, Serialize)]
120struct ElasticReply {
121    errors: bool,
122    items: Vec<ElasticSingleReplyIndex>,
123}
124
125#[derive(serde::Deserialize, Serialize)]
126struct ElasticErrorReplyError {
127    reason: String,
128    r#type: String,
129}
130
131#[derive(serde::Deserialize, Serialize)]
132struct ElasticErrorReply {
133    error: ElasticErrorReplyError,
134    status: u16,
135}
136
137pub fn create_console_message<T>(level: &LogLevel, message: &str, time: &DateTime<T>) -> String
138where
139    T: TimeZone,
140{
141    let time_str = time.time().format("%H:%M:%S");
142    format!("[{} {}]: {}", time_str, level, message)
143}
144
145pub fn log<S>(level: LogLevel, template: &str, fields: HashMap<S, String>)
146where
147    S: std::fmt::Display,
148{
149    let mut message = template.to_string();
150    for (key, value) in fields.iter() {
151        message = message.replacen(format!("{{{}}}", key).as_str(), value.as_str(), 1);
152    }
153
154    let mut fields_copy = HashMap::<String, String>::with_capacity(fields.len());
155    for (key, value) in fields.into_iter() {
156        fields_copy.insert(key.to_string(), value);
157    }
158
159    let log_msg = LogMessage {
160        level,
161        message_template: template.to_string(),
162        message,
163        fields: fields_copy,
164    };
165
166    {
167        let logger = LOG.read().unwrap();
168        if let Some(logger_) = &*logger {
169            logger_
170                .send(log_msg)
171                .expect("Relastic queue has stopped working");
172            return; // This will short circuit the logging and prevent console logging
173        }
174    }
175    let time = chrono::Local::now();
176    if LOG_TO_ELASTIC.load(std::sync::atomic::Ordering::SeqCst) {
177        println!(
178            "{}",
179            create_console_message(
180                &LogLevel::Warning,
181                "Could not log to elastic. Logging to console as fallback",
182                &time
183            )
184        );
185    }
186    println!(
187        "{}",
188        create_console_message(&log_msg.level, &log_msg.message, &time)
189    );
190}
191
192pub fn get_matches(input: &str) -> std::collections::VecDeque<String> {
193    let mut matches = std::collections::VecDeque::new();
194    let mut word = std::string::String::new();
195    for c in input.chars() {
196        match c {
197            '{' => {
198                // Reset word.
199                word.clear();
200            }
201            '}' => {
202                matches.push_back(word.clone());
203                word.clear();
204            }
205            _ => {
206                word.push(c);
207            }
208        }
209    }
210    matches
211}
212
213#[macro_export]
214macro_rules! log_debug {
215    ($fmt: literal, $($arg:expr),*) => {
216        {
217            let mut matches = $crate::log::get_matches($fmt);
218            let mut map = std::collections::HashMap::new();
219            $(
220                if let Some(log_variable_name) = matches.pop_front()
221                {
222                    map.insert(log_variable_name, $arg.to_string());
223                }
224            )*
225
226            $crate::log::log($crate::log::LogLevel::Debug, $fmt, map);
227        }
228    }
229}
230
231#[macro_export]
232macro_rules! log_information {
233    ($fmt: literal, $($arg:expr),*) => {
234        {
235            let mut matches = $crate::log::get_matches($fmt);
236            let mut map = std::collections::HashMap::new();
237            $(
238                if let Some(log_variable_name) = matches.pop_front()
239                {
240                    map.insert(log_variable_name, $arg.to_string());
241                }
242            )*
243
244            $crate::log::log($crate::log::LogLevel::Information, $fmt, map);
245        }
246    }
247}
248
249#[macro_export]
250macro_rules! log_warning {
251    ($fmt: literal, $($arg:expr),*) => {
252        {
253            let mut matches = $crate::log::get_matches($fmt);
254            let mut map = std::collections::HashMap::new();
255            $(
256                if let Some(log_variable_name) = matches.pop_front()
257                {
258                    map.insert(log_variable_name, $arg.to_string());
259                }
260            )*
261
262            $crate::log::log($crate::log::LogLevel::Warning, $fmt, map);
263        }
264    }
265}
266
267#[macro_export]
268macro_rules! log_error {
269    ($fmt: literal, $($arg:expr),*) => {
270        {
271            let mut matches = $crate::log::get_matches($fmt);
272            let mut map = std::collections::HashMap::new();
273            $(
274                if let Some(log_variable_name) = matches.pop_front()
275                {
276                    map.insert(log_variable_name, $arg.to_string());
277                }
278            )*
279
280            $crate::log::log($crate::log::LogLevel::Error, $fmt, map);
281        }
282    }
283}
284
285#[macro_export]
286macro_rules! log_fatal {
287    ($fmt: literal, $($arg:expr),*) => {
288        {
289            let mut matches = $crate::log::get_matches($fmt);
290            let mut map = std::collections::HashMap::new();
291            $(
292                if let Some(log_variable_name) = matches.pop_front()
293                {
294                    map.insert(log_variable_name, $arg.to_string());
295                }
296            )*
297
298            $crate::log::log($crate::log::LogLevel::Fatal, $fmt, map);
299        }
300    }
301}
302
303/// Logs message with 'information' as severity level.
304/// Feel free to use this to log any information you may want to inspect.
305/// /// # Examples
306///
307/// ```
308///
309/// relastic::log::information(
310///     "This is my test message: {msg}",
311///     std::collections::HashMap::from([("msg", "Hello World!".to_string())])
312/// );
313///
314/// ```
315pub fn information(template: &str, fields: HashMap<&str, String>) {
316    log(LogLevel::Information, template, fields)
317}
318
319/// Logs message with 'error' as severity level.
320/// This should be used if a critical error that needs too be looked at occurs.
321/// The application must be able to recover, and unrelated data-loss should not happen when using this.
322///
323/// ```
324///
325/// relastic::log::error(
326///     "This is my test error: {error}",
327///     std::collections::HashMap::from([("error", "Hello World!".to_string())])
328/// );
329///
330/// ```
331pub fn error(template: &str, fields: HashMap<&str, String>) {
332    log(LogLevel::Error, template, fields)
333}
334
335/// Logs message with 'debug' as severity level.
336/// This should be used for debug-purposes only. Messages will be ignored if environment is set to production.
337///
338/// ```
339///
340/// relastic::log::debug(
341///     "This is my test message: {msg}",
342///     std::collections::HashMap::from([("msg", "Hello World!".to_string())])
343/// );
344///
345/// ```
346pub fn debug(template: &str, fields: HashMap<&str, String>) {
347    log(LogLevel::Debug, template, fields)
348}
349
350/// Logs message with 'fatal' as severity level.
351/// This should be used if an unrecoverable error occurs, that leaves the application with no choice but to shut down.
352///
353/// ```
354///
355/// relastic::log::fatal(
356///     "This is my test error: {error}",
357///     std::collections::HashMap::from([("error", "Hello World!".to_string())])
358/// );
359///
360/// ```
361pub fn fatal(template: &str, fields: HashMap<&str, String>) {
362    log(LogLevel::Fatal, template, fields)
363}
364
365/// Logs message with 'warning' as severity level.
366/// This should be used if an error or issue occurs that may may have to be looked at,
367/// but is unimportant for application logic's continuous success.
368///
369/// ```
370///
371/// relastic::log::warning(
372///     "This is my test error: {error}",
373///     std::collections::HashMap::from([("error", "Hello World!".to_string())])
374/// );
375///
376/// ```
377pub fn warning(template: &str, fields: HashMap<&str, String>) {
378    log(LogLevel::Warning, template, fields)
379}
380
381/// Closes the sender, and publishes remaining logs before closing the publish loop.
382/// You should call this before your application completes/returns so that no logs are lost.
383pub fn flush() {
384    println!("Waiting for log grace-period to expire...");
385    thread::sleep(WAIT);
386    println!("Closing log transceiver");
387    {
388        let mut sender = LOG.write().unwrap();
389        *sender = None;
390    }
391    println!("Flushing logs");
392    SHOULD_LOOP.store(false, std::sync::atomic::Ordering::SeqCst);
393    while !IS_FLUSHED.load(std::sync::atomic::Ordering::SeqCst) {
394        println!("Waiting for logs to flush...");
395        thread::sleep(WAIT);
396    }
397    println!("Flushed logs");
398}
399
400#[derive(Serialize)]
401struct LogPayload {
402    #[serde(rename(serialize = "@timestamp"))]
403    timestamp: String,
404    level: String,
405    #[serde(rename(serialize = "messageTemplate"))]
406    message_template: String,
407    message: String,
408    fields: HashMap<String, String>,
409}
410
411/// Elastic configuration.
412/// Required to set up logging to an Elastic cluster.
413/// Default without configuration: Log to console
414#[derive(Clone, PartialEq)]
415pub struct ElasticConfig {
416    /// Elastic cluster username
417    pub username: String,
418    /// Elastic cluster password
419    pub password: String,
420    /// Elastic cluster URL
421    pub url: String,
422    /// LogEnvironment for your application. Debug will not be logged in Production.
423    pub environment: LogEnvironment,
424    /// Name of application to use. This is what you will use to setup filtering in kibana later.
425    pub application_name: String,
426    /// Whether to also log to console. Default: true.
427    pub log_to_console: Option<bool>,
428}
429
430fn concat_fields(
431    default_fields: &HashMap<String, String>,
432    extra_fields: HashMap<String, String>,
433) -> HashMap<String, String> {
434    let mut all_fields = HashMap::new();
435    for (key, value) in default_fields.iter() {
436        all_fields.insert(key.to_owned(), value.to_owned());
437    }
438    for (key, value) in extra_fields.into_iter() {
439        all_fields.insert(key, value);
440    }
441    all_fields
442}
443
444#[cfg(test)]
445pub fn test_post_to_elastic(elastic_credentials: ElasticConfig, json: String) -> Result<(), Error> {
446    let client = reqwest::blocking::Client::new();
447    post_to_elastic(client, elastic_credentials, json)
448}
449
450fn post_to_elastic(
451    client: reqwest::blocking::Client,
452    elastic_credentials: ElasticConfig,
453    json: String,
454) -> Result<(), Error> {
455    let result = client
456        .post(format!("{}/_bulk", elastic_credentials.url))
457        .basic_auth(
458            elastic_credentials.username,
459            Some(elastic_credentials.password),
460        )
461        .header("Content-Type", "application/json")
462        .header("Accept", "application/json")
463        .body(json.to_owned())
464        .send()
465        .map_err(|x| Error::RequestFailed { inner: x })?;
466    if result.status().is_success() {
467        let ok_result = result
468            .json::<ElasticReply>()
469            .map_err(|x| Error::DeserializationFailed { inner: x })?;
470        let mut errors = Vec::<String>::new();
471        for index in ok_result.items.into_iter() {
472            if let Some(some_error) = index.index.error {
473                errors.push(format!(
474                    "[{}]: {}",
475                    some_error.caused_by.r#type, some_error.caused_by.reason
476                ));
477            }
478        }
479        if !errors.is_empty() {
480            return Err(Error::SomeLogsWereNotAccepted {
481                errors: errors.join("\n"),
482            });
483        }
484        return Ok(());
485    }
486
487    let err_result = result.json::<ElasticErrorReply>();
488    if let Ok(some_err_result) = err_result {
489        Err(Error::ApiRejectedLogPayload {
490            errors: json!(some_err_result).to_string(),
491        })
492    } else {
493        Err(Error::ApiRejectedLogPayload {
494            errors: format!("[Unhandled Error]: Payload {}", json),
495        })
496    }
497}
498
499fn serialize_to_chunks(log_chunks: Vec<(LogIndex, LogPayload)>) -> String {
500    let mut chunks = Vec::<String>::new();
501    for log_chunk in log_chunks.into_iter() {
502        chunks.push(json!(log_chunk.0).to_string());
503        chunks.push(json!(log_chunk.1).to_string());
504    }
505    // Elastic requires the payload to end with a newline
506    chunks.push("\n".to_string());
507    chunks.join("\n")
508}
509
510fn log_to_elastic(
511    client: reqwest::blocking::Client,
512    elastic_credentials: ElasticConfig,
513    log_chunks: Vec<(LogIndex, LogPayload)>,
514) {
515    let json = serialize_to_chunks(log_chunks);
516    let result = post_to_elastic(client, elastic_credentials, json);
517    if let Err(result_err) = result {
518        eprintln!("error: {}", result_err)
519    }
520}
521
522pub fn setup_console_log() {
523    LOG_TO_CONSOLE.store(true, std::sync::atomic::Ordering::SeqCst);
524    SHOULD_LOOP.store(false, std::sync::atomic::Ordering::SeqCst);
525    IS_FLUSHED.store(true, std::sync::atomic::Ordering::SeqCst);
526    LOG_TO_ELASTIC.store(false, std::sync::atomic::Ordering::SeqCst);
527    println!(
528        "{}",
529        create_console_message(
530            &LogLevel::Information,
531            "Relastic initialized to log to console",
532            &Local::now()
533        )
534    );
535}
536
537/// Sets up the log service. Call this before calling the log::<severity> functions.
538/// To prevent loss of logs on application shutdown, make sure call log::flush() before the fact.
539///
540/// # Examples
541///
542/// ```
543///
544///
545/// let my_config = relastic::log::ElasticConfig {
546///        url: "https://localhost".to_string(),
547///        username: "test_user".to_string(),
548///        password: "test_password".to_string(),
549///        environment: relastic::log::LogEnvironment::Production,
550///        application_name: "relastic-tests".to_string(),
551///        log_to_console: Some(false)
552/// };
553///
554/// relastic::log::setup_elastic_log(my_config, 100);
555///
556/// relastic::log::information("This is a test: {text}", std::collections::HashMap::from([("text", "Hello World".to_string())]));
557///
558/// relastic::log::flush();
559///
560/// ```
561pub fn setup_elastic_log(config: ElasticConfig, buffer_size: usize) {
562    LOG_TO_CONSOLE.store(
563        config.log_to_console.unwrap_or(true),
564        std::sync::atomic::Ordering::SeqCst,
565    );
566    LOG_TO_ELASTIC.store(true, std::sync::atomic::Ordering::SeqCst);
567    let client = reqwest::blocking::Client::new();
568    let (tx, rx) = mpsc::sync_channel::<LogMessage>(buffer_size);
569    {
570        let mut logger = LOG.write().unwrap();
571        *logger = Some(tx);
572    }
573    let app_name_lowercase = config.application_name.to_lowercase();
574    let env_str_lowercase = config.environment.to_owned().to_string().to_lowercase();
575    let default_fields = HashMap::from([
576        ("ApplicationName".to_string(), app_name_lowercase.to_owned()),
577        (
578            "HostingEnvironment".to_string(),
579            env_str_lowercase.to_owned(),
580        ),
581    ]);
582
583    SHOULD_LOOP.store(true, std::sync::atomic::Ordering::SeqCst);
584
585    thread::spawn(move || {
586        loop {
587            let mut payload_chunks = Vec::<(LogIndex, LogPayload)>::new();
588            let next_logs = rx.recv_timeout(WAIT);
589            if next_logs.is_err() {
590                // No logs to flush
591                if !SHOULD_LOOP.load(std::sync::atomic::Ordering::SeqCst) {
592                    IS_FLUSHED.store(true, std::sync::atomic::Ordering::SeqCst);
593                    return;
594                }
595                continue;
596            }
597            for received in next_logs.into_iter() {
598                let dt = Utc::now();
599                let str_index: String = format!(
600                    "{}-{}-{}.{}",
601                    app_name_lowercase,
602                    env_str_lowercase,
603                    dt.date_naive().year(),
604                    dt.date_naive().month()
605                );
606                let index = LogIndex {
607                    index: LogInnerIndex { index: str_index },
608                };
609                let log_payload = LogPayload {
610                    timestamp: dt.to_rfc3339_opts(SecondsFormat::Nanos, true),
611                    level: received.level.to_string(),
612                    message_template: received.message_template,
613                    message: received.message,
614                    fields: concat_fields(&default_fields, received.fields),
615                };
616
617                // Skip logging debug if env is production
618                if received.level == LogLevel::Debug
619                    && config.environment == LogEnvironment::Production
620                {
621                    continue;
622                }
623                // Log to console if configured to
624                if LOG_TO_CONSOLE.load(std::sync::atomic::Ordering::SeqCst) {
625                    let local_time = Local::now();
626                    println!(
627                        "{}",
628                        create_console_message(&received.level, &log_payload.message, &local_time)
629                    );
630                }
631                payload_chunks.push((index, log_payload));
632            }
633            if !payload_chunks.is_empty() {
634                log_to_elastic(client.to_owned(), config.clone(), payload_chunks);
635            }
636            if !SHOULD_LOOP.load(std::sync::atomic::Ordering::SeqCst) {
637                IS_FLUSHED.store(true, std::sync::atomic::Ordering::SeqCst);
638                return;
639            }
640        }
641    });
642}