Skip to content

Commit b12f378

Browse files
authored
feat: support journald appender (#80)
Signed-off-by: tison <[email protected]>
1 parent b63071d commit b12f378

File tree

9 files changed

+731
-2
lines changed

9 files changed

+731
-2
lines changed

CHANGELOG.md

+15
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.18.0] 2024-11-14
6+
7+
### Breaking changes
8+
9+
* The mapping between syslog severity and log's level is changed.
10+
* `log::Level::Error` is mapped to `syslog::Severity::Error` (unchanged).
11+
* `log::Level::Warn` is mapped to `syslog::Severity::Warning` (unchanged).
12+
* `log::Level::Info` is mapped to `syslog::Severity::Notice` (changed).
13+
* `log::Level::Debug` is mapped to `syslog::Severity::Info` (changed).
14+
* `log::Level::Trace` is mapped to `syslog::Severity::Debug` (unchanged).
15+
16+
### New features
17+
18+
* Add `journald` feature to support journald appenders ([#80](https://github.com/fast/logforth/pull/80)).
19+
520
## [0.17.1] 2024-11-12
621

722
### Refactors

Cargo.toml

+8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ rustdoc-args = ["--cfg", "docsrs"]
3333

3434
[features]
3535
fastrace = ["dep:fastrace"]
36+
journald = ["dep:libc"]
3637
json = ["dep:serde_json", "dep:serde", "jiff/serde"]
3738
no-color = ["colored/no-color"]
3839
non-blocking = ["dep:crossbeam-channel"]
@@ -55,6 +56,7 @@ log = { version = "0.4", features = ["std", "kv_unstable"] }
5556
crossbeam-channel = { version = "0.5", optional = true }
5657
fastrace = { version = "0.7", optional = true }
5758
fasyslog = { version = "0.2", optional = true }
59+
libc = { version = "0.2.162", optional = true }
5860
opentelemetry = { version = "0.27", features = ["logs"], optional = true }
5961
opentelemetry-otlp = { version = "0.27", features = [
6062
"logs",
@@ -107,3 +109,9 @@ doc-scrape-examples = true
107109
name = "syslog"
108110
path = "examples/syslog.rs"
109111
required-features = ["syslog"]
112+
113+
[[example]]
114+
doc-scrape-examples = true
115+
name = "journald"
116+
path = "examples/journald.rs"
117+
required-features = ["journald"]

examples/journald.rs

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2024 FastLabs Developers
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#[cfg(unix)]
16+
fn main() {
17+
use logforth::append::Journald;
18+
19+
let append = Journald::new().unwrap();
20+
logforth::builder().dispatch(|d| d.append(append)).apply();
21+
22+
log::error!("Hello, journald at ERROR!");
23+
log::warn!("Hello, journald at WARN!");
24+
log::info!("Hello, journald at INFO!");
25+
log::debug!("Hello, journald at DEBUG!");
26+
log::trace!("Hello, journald at TRACE!");
27+
}
28+
29+
#[cfg(not(unix))]
30+
fn main() {
31+
println!("This example is only for Unix-like systems.");
32+
}

src/append/journald/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Rolling File Appender
2+
3+
This appender is a remix of [tracing-journald](https://crates.io/crates/tracing-journald) and [systemd-journal-logger](https://crates.io/crates/systemd-journal-logger), with several modifications to fit this crate's needs.

src/append/journald/field.rs

+195
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright 2024 FastLabs Developers
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// This field is derived from https://github.com/swsnr/systemd-journal-logger.rs/blob/v2.2.0/src/fields.rs.
16+
17+
//! Write well-formatted journal fields to buffers.
18+
19+
use std::fmt::Arguments;
20+
use std::io::Write;
21+
22+
use log::kv::Value;
23+
24+
pub(super) enum FieldName<'a> {
25+
WellFormed(&'a str),
26+
WriteEscaped(&'a str),
27+
}
28+
29+
/// Whether `c` is a valid character in the key of a journal field.
30+
///
31+
/// Journal field keys may only contain ASCII uppercase letters A to Z,
32+
/// numbers 0 to 9 and the underscore.
33+
fn is_valid_key_char(c: char) -> bool {
34+
matches!(c, 'A'..='Z' | '0'..='9' | '_')
35+
}
36+
37+
/// Write an escaped `key` for use in a systemd journal field.
38+
///
39+
/// See [`super::Journald`] for the rules.
40+
fn write_escaped_key(key: &str, buffer: &mut Vec<u8>) {
41+
// Key length is limited to 64 bytes
42+
let mut remaining = 64;
43+
44+
let escaped = key
45+
.to_ascii_uppercase()
46+
.replace(|c| !is_valid_key_char(c), "_");
47+
48+
if escaped.starts_with(|c: char| matches!(c, '_' | '0'..='9')) {
49+
buffer.extend_from_slice(b"ESCAPED_");
50+
remaining -= 8;
51+
}
52+
53+
for b in escaped.into_bytes() {
54+
if remaining == 0 {
55+
break;
56+
}
57+
buffer.push(b);
58+
remaining -= 1;
59+
}
60+
}
61+
62+
fn put_field_name(buffer: &mut Vec<u8>, name: FieldName<'_>) {
63+
match name {
64+
FieldName::WellFormed(name) => buffer.extend_from_slice(name.as_bytes()),
65+
FieldName::WriteEscaped("") => buffer.extend_from_slice(b"EMPTY"),
66+
FieldName::WriteEscaped(name) => write_escaped_key(name, buffer),
67+
}
68+
}
69+
70+
pub(super) trait PutAsFieldValue {
71+
fn put_field_value(self, buffer: &mut Vec<u8>);
72+
}
73+
74+
impl PutAsFieldValue for &[u8] {
75+
fn put_field_value(self, buffer: &mut Vec<u8>) {
76+
buffer.extend_from_slice(self)
77+
}
78+
}
79+
80+
impl PutAsFieldValue for &Arguments<'_> {
81+
fn put_field_value(self, buffer: &mut Vec<u8>) {
82+
match self.as_str() {
83+
Some(s) => buffer.extend_from_slice(s.as_bytes()),
84+
None => {
85+
// SAFETY: no more than an allocate-less version
86+
// buffer.extend_from_slice(format!("{}", self))
87+
write!(buffer, "{}", self).unwrap()
88+
}
89+
}
90+
}
91+
}
92+
93+
impl PutAsFieldValue for Value<'_> {
94+
fn put_field_value(self, buffer: &mut Vec<u8>) {
95+
// SAFETY: no more than an allocate-less version
96+
// buffer.extend_from_slice(format!("{}", self))
97+
write!(buffer, "{}", self).unwrap();
98+
}
99+
}
100+
101+
pub(super) fn put_field_length_encoded<V: PutAsFieldValue>(
102+
buffer: &mut Vec<u8>,
103+
name: FieldName<'_>,
104+
value: V,
105+
) {
106+
put_field_name(buffer, name);
107+
buffer.push(b'\n');
108+
// Reserve the length tag
109+
buffer.extend_from_slice(&[0; 8]);
110+
let value_start = buffer.len();
111+
value.put_field_value(buffer);
112+
let value_end = buffer.len();
113+
// Fill the length tag
114+
let length_bytes = ((value_end - value_start) as u64).to_le_bytes();
115+
buffer[value_start - 8..value_start].copy_from_slice(&length_bytes);
116+
buffer.push(b'\n');
117+
}
118+
119+
pub(super) fn put_field_bytes(buffer: &mut Vec<u8>, name: FieldName<'_>, value: &[u8]) {
120+
if value.contains(&b'\n') {
121+
// Write as length encoded field
122+
put_field_length_encoded(buffer, name, value);
123+
} else {
124+
put_field_name(buffer, name);
125+
buffer.push(b'=');
126+
buffer.extend_from_slice(value);
127+
buffer.push(b'\n');
128+
}
129+
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use FieldName::*;
134+
135+
use super::*;
136+
137+
#[test]
138+
fn test_escape_journal_key() {
139+
for case in ["FOO", "FOO_123"] {
140+
let mut bs = vec![];
141+
write_escaped_key(case, &mut bs);
142+
assert_eq!(String::from_utf8_lossy(&bs), case);
143+
}
144+
145+
let cases = vec![
146+
("foo", "FOO"),
147+
("_foo", "ESCAPED__FOO"),
148+
("1foo", "ESCAPED_1FOO"),
149+
("Hallöchen", "HALL_CHEN"),
150+
];
151+
for (key, expected) in cases {
152+
let mut bs = vec![];
153+
write_escaped_key(key, &mut bs);
154+
assert_eq!(String::from_utf8_lossy(&bs), expected);
155+
}
156+
157+
{
158+
for case in [
159+
"very_long_key_name_that_is_longer_than_64_bytes".repeat(5),
160+
"_need_escape_very_long_key_name_that_is_longer_than_64_bytes".repeat(5),
161+
] {
162+
let mut bs = vec![];
163+
write_escaped_key(&case, &mut bs);
164+
println!("{:?}", String::from_utf8_lossy(&bs));
165+
assert_eq!(bs.len(), 64);
166+
}
167+
}
168+
}
169+
170+
#[test]
171+
fn test_put_field_length_encoded() {
172+
let mut buffer = Vec::new();
173+
// See "Data Format" in https://systemd.io/JOURNAL_NATIVE_PROTOCOL/ for this example
174+
put_field_length_encoded(&mut buffer, WellFormed("FOO"), "BAR".as_bytes());
175+
assert_eq!(&buffer, b"FOO\n\x03\0\0\0\0\0\0\0BAR\n");
176+
}
177+
178+
#[test]
179+
fn test_put_field_bytes_no_newline() {
180+
let mut buffer = Vec::new();
181+
put_field_bytes(&mut buffer, WellFormed("FOO"), "BAR".as_bytes());
182+
assert_eq!(&buffer, b"FOO=BAR\n");
183+
}
184+
185+
#[test]
186+
fn test_put_field_bytes_newline() {
187+
let mut buffer = Vec::new();
188+
put_field_bytes(
189+
&mut buffer,
190+
WellFormed("FOO"),
191+
"BAR\nSPAM_WITH_EGGS".as_bytes(),
192+
);
193+
assert_eq!(&buffer, b"FOO\n\x12\0\0\0\0\0\0\0BAR\nSPAM_WITH_EGGS\n");
194+
}
195+
}

0 commit comments

Comments
 (0)