-
Notifications
You must be signed in to change notification settings - Fork 12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(spdlog): impl JsonFormatter #69
Changes from 13 commits
20ba1c3
83d9da8
453757b
c9a5eb7
d70c801
f822ec6
c3c2636
e43aadf
7c7349a
929fe9d
0c83e4e
715d879
99b8736
11df475
10f17d3
8d7a1e1
3c4c229
d8f091e
18edfe4
e698a45
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,190 @@ | ||||||||
use std::{ | ||||||||
fmt::{self, Write}, | ||||||||
marker::PhantomData, | ||||||||
time::SystemTime, | ||||||||
}; | ||||||||
|
||||||||
use cfg_if::cfg_if; | ||||||||
use serde::{ser::SerializeStruct, Serialize}; | ||||||||
|
||||||||
use crate::{ | ||||||||
formatter::{FmtExtraInfo, Formatter}, | ||||||||
Error, Record, StringBuf, __EOL, | ||||||||
}; | ||||||||
|
||||||||
struct JsonRecord<'a>(&'a Record<'a>); | ||||||||
|
||||||||
impl<'a> Serialize for JsonRecord<'a> { | ||||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> | ||||||||
where | ||||||||
S: serde::Serializer, | ||||||||
{ | ||||||||
let src_loc = self.0.source_location(); | ||||||||
|
||||||||
let mut record = | ||||||||
serializer.serialize_struct("JsonRecord", if src_loc.is_none() { 4 } else { 5 })?; | ||||||||
|
||||||||
record.serialize_field("level", &self.0.level())?; | ||||||||
record.serialize_field( | ||||||||
"timestamp", | ||||||||
&self | ||||||||
.0 | ||||||||
.time() | ||||||||
.duration_since(SystemTime::UNIX_EPOCH) | ||||||||
.expect("invalid timestamp") | ||||||||
.as_secs() | ||||||||
.to_string(), | ||||||||
)?; | ||||||||
record.serialize_field("tid", &self.0.tid())?; | ||||||||
record.serialize_field("payload", self.0.payload())?; | ||||||||
|
||||||||
if let Some(src_loc) = src_loc { | ||||||||
record.serialize_field("source", src_loc)?; | ||||||||
} | ||||||||
|
||||||||
record.end() | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
impl<'a> From<&'a Record<'a>> for JsonRecord<'a> { | ||||||||
fn from(value: &'a Record<'a>) -> Self { | ||||||||
JsonRecord(value) | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
enum JsonFormatterError { | ||||||||
Fmt(fmt::Error), | ||||||||
Serialization(serde_json::Error), | ||||||||
} | ||||||||
|
||||||||
impl From<fmt::Error> for JsonFormatterError { | ||||||||
fn from(value: fmt::Error) -> Self { | ||||||||
JsonFormatterError::Fmt(value) | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
impl From<serde_json::Error> for JsonFormatterError { | ||||||||
fn from(value: serde_json::Error) -> Self { | ||||||||
JsonFormatterError::Serialization(value) | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
impl From<JsonFormatterError> for crate::Error { | ||||||||
fn from(value: JsonFormatterError) -> Self { | ||||||||
match value { | ||||||||
JsonFormatterError::Fmt(e) => Error::FormatRecord(e), | ||||||||
JsonFormatterError::Serialization(e) => Error::SerializeRecord(e.into()), | ||||||||
} | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
#[rustfmt::skip] | ||||||||
/// JSON logs formatter | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The doc formatting seems to be broken by Rustfmt, try adding a |
||||||||
/// | ||||||||
/// Output format: | ||||||||
/// | ||||||||
/// ```json | ||||||||
/// {"level":"info","timestamp":"2024-01-01 | ||||||||
/// 12:00:00","tid":123456,"payload":"test"} | ||||||||
/// | ||||||||
/// // with source location | ||||||||
/// {"level":"info","timestamp":"2024-01-01 | ||||||||
/// 12:00:00","tid":123456,"payload":"test","source_location":{"module_path":" | ||||||||
/// module","file":"file.rs","line":42}} | ||||||||
/// ``` | ||||||||
#[derive(Clone)] | ||||||||
pub struct JsonFormatter(PhantomData<()>); | ||||||||
|
||||||||
impl JsonFormatter { | ||||||||
/// Create a `JsonFormatter` | ||||||||
#[must_use] | ||||||||
pub fn new() -> JsonFormatter { | ||||||||
SpriteOvO marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
JsonFormatter(PhantomData) | ||||||||
} | ||||||||
|
||||||||
fn format_impl( | ||||||||
&self, | ||||||||
record: &Record, | ||||||||
dest: &mut StringBuf, | ||||||||
) -> Result<FmtExtraInfo, JsonFormatterError> { | ||||||||
cfg_if! { | ||||||||
if #[cfg(not(feature = "flexible-string"))] { | ||||||||
dest.reserve(crate::string_buf::RESERVE_SIZE); | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
let json_record: JsonRecord = record.into(); | ||||||||
|
||||||||
dest.write_str(&serde_json::to_string(&json_record)?)?; | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving this comment as a note.
I benchmarked Unfortunately, due to the |
||||||||
|
||||||||
dest.write_str(__EOL)?; | ||||||||
|
||||||||
Ok(FmtExtraInfo { style_range: None }) | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
impl Formatter for JsonFormatter { | ||||||||
fn format(&self, record: &Record, dest: &mut StringBuf) -> crate::Result<FmtExtraInfo> { | ||||||||
self.format_impl(record, dest).map_err(Into::into) | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
impl Default for JsonFormatter { | ||||||||
fn default() -> Self { | ||||||||
JsonFormatter::new() | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
#[cfg(test)] | ||||||||
mod tests { | ||||||||
use chrono::prelude::*; | ||||||||
|
||||||||
use super::*; | ||||||||
use crate::{Level, SourceLocation, __EOL}; | ||||||||
|
||||||||
#[test] | ||||||||
fn should_format_json() { | ||||||||
let mut dest = StringBuf::new(); | ||||||||
let formatter = JsonFormatter::new(); | ||||||||
let record = Record::builder(Level::Info, "payload").build(); | ||||||||
let extra_info = formatter.format(&record, &mut dest).unwrap(); | ||||||||
|
||||||||
let local_time: DateTime<Local> = record.time().into(); | ||||||||
|
||||||||
assert_eq!(extra_info.style_range, None); | ||||||||
assert_eq!( | ||||||||
dest.to_string(), | ||||||||
format!( | ||||||||
r#"{{"level":"Info","timestamp":"{}","tid":{},"payload":"{}"}}{}"#, | ||||||||
local_time.timestamp(), | ||||||||
record.tid(), | ||||||||
"payload", | ||||||||
__EOL | ||||||||
) | ||||||||
); | ||||||||
} | ||||||||
|
||||||||
#[test] | ||||||||
fn should_format_json_with_src_loc() { | ||||||||
let mut dest = StringBuf::new(); | ||||||||
let formatter = JsonFormatter::new(); | ||||||||
let record = Record::builder(Level::Info, "payload") | ||||||||
.source_location(Some(SourceLocation::__new("module", "file.rs", 1, 2))) | ||||||||
.build(); | ||||||||
let extra_info = formatter.format(&record, &mut dest).unwrap(); | ||||||||
|
||||||||
let local_time: DateTime<Local> = record.time().into(); | ||||||||
|
||||||||
assert_eq!(extra_info.style_range, None); | ||||||||
assert_eq!( | ||||||||
dest.to_string(), | ||||||||
format!( | ||||||||
r#"{{"level":"Info","timestamp":"{}","tid":{},"payload":"{}","source":{{"module_path":"module","file":"file.rs","line":1,"column":2}}}}{}"#, | ||||||||
local_time.timestamp(), | ||||||||
record.tid(), | ||||||||
"payload", | ||||||||
__EOL | ||||||||
) | ||||||||
); | ||||||||
} | ||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've discussed with @NotEvenANeko about this in private messages.
Initially I simply thought that UNIX timestamp would be OK, but then realized that it is in seconds, which is obviously not precise enough for logging. After some investigation, I think a millisecond timestamp might be more appropriate, based on the following clues:
I prefer not to format it to a human-readable string (e.g. ISO 8601 / RFC 3339) because JSON will eventually be parsed by a machine. And the performance of date-time formatting is quite bad for both serialization and deserialization.
in JavaScript,
Date::now
and the constructor ofDate
returns and accepts a number of milliseconds since January 1, 1970 00:00:00 UTC, with leap seconds ignored.most programming languages have time libraries that support parsing millisecond timestamps directly or indirectly.
nanoseconds are more precise, but it uses more significant integer digits, and perhaps this precision is unnecessary.
Since this integer will eventually be expressed in JSON, and
Duration::as_millis
returns au128
, we need to convert it tou64
to express it in JSON. Although the maximum safe type for JSON integers in JavaScript isi53
, I tested it with Rustchrono
crate, and the maximum millisecond timestamp it supports parsing doesn't even exceedi53::MAX
, and that maximum timestamp has reached the year 262142. So in my opinion, representing it as a string is not necessary for the foreseeable future.Using
u64::try_from
rather thanas u64
here so we can catch the overflow error instead of implicit truncation.