Skip to main content

http_wasm_guest/
host_logger.rs

1use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
2use std::io::Write;
3
4use crate::{host, memory};
5
6static LOGGER: HostLogger = HostLogger;
7const TRUNC_MARKER: &[u8] = b"... [truncated]";
8
9/// Logger implementation that forwards records to the host.
10///
11/// This integrates the Rust `log` crate with the http-wasm guest runtime's logging system.
12/// It provides logging for plugin authors via standard macros (`log::info!`, `log::warn!`, etc.).
13pub struct HostLogger;
14
15impl Log for HostLogger {
16    #[inline]
17    fn enabled(&self, metadata: &Metadata) -> bool {
18        metadata.level() <= log::max_level()
19    }
20
21    fn log(&self, record: &Record) {
22        if self.enabled(record.metadata()) {
23            memory::with_buffer(|buf| {
24                let written = format_log_message(buf, record.args());
25                host::log::write(host_level(record.metadata()), buf.as_subslice(written));
26            });
27        }
28    }
29
30    fn flush(&self) {}
31}
32
33/// Formats the log message into the provided buffer, applying truncation if needed.
34/// Returns the number of bytes written.
35fn format_log_message(buf: &mut memory::Buffer, args: &std::fmt::Arguments) -> usize {
36    let capacity = buf.capacity();
37    let mut slice = buf.as_mut_slice();
38    match write!(slice, "{}", args) {
39        Ok(()) => capacity - slice.len(),
40        Err(_) => {
41            let start = capacity - TRUNC_MARKER.len();
42            let slice = buf.as_mut_slice();
43            slice[start..].copy_from_slice(TRUNC_MARKER);
44            buf.capacity()
45        }
46    }
47}
48
49impl HostLogger {
50    /// Initialize the host-backed logger with the default Info level.
51    ///
52    /// This is a convenience function for [`init_with_level`] using `Level::Info`.
53    #[inline]
54    pub fn init() -> Result<(), SetLoggerError> {
55        HostLogger::init_with_level(Level::Info)
56    }
57
58    /// Initialize the host-backed logger with a specific maximum level.
59    ///
60    /// This registers a HostLogger implementation for forwarding log records to the http-wasm host.
61    #[inline]
62    pub fn init_with_level(level: Level) -> Result<(), SetLoggerError> {
63        log::set_max_level(max_level(level.to_level_filter()));
64        log::set_logger(&LOGGER)
65    }
66}
67
68/// Determine the max_log_level as configured by the host.
69/// If the log-level is more restrictive on the host than the plugin tries to configure,
70/// the level is decremented until an enabled level is found or Off is reached.
71fn max_level(level_filter: LevelFilter) -> LevelFilter {
72    max_level_with(level_filter, |level| host::log::enabled(map_to_host(level)))
73}
74
75/// Core max-level selection logic parameterized by a host enable-check.
76fn max_level_with(mut level_filter: LevelFilter, is_enabled: impl Fn(Level) -> bool) -> LevelFilter {
77    while let Some(level) = level_filter.to_level() {
78        if is_enabled(level) {
79            return level_filter;
80        }
81        level_filter = level_filter.decrement_severity();
82    }
83    LevelFilter::Off
84}
85
86/// Map a Rust `log::Level` to the host severity code.
87///
88/// per spec: debug -1, info 0, warn 1, error 2, none 3
89/// traefik logs with trace -2, debug -1, info 0, warn 1, error 2, (fatal 3)
90fn map_to_host(level: Level) -> i32 {
91    match level {
92        Level::Error => 2,
93        Level::Warn => 1,
94        Level::Info => 0,
95        Level::Debug => -1,
96        Level::Trace => -2,
97    }
98}
99
100fn host_level(md: &Metadata) -> i32 {
101    map_to_host(md.level())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_init_with_level() {
110        // Logger can only be set once globally, so we just verify it doesn't panic
111        // and returns a result (either Ok or Err if already set)
112        let _result = HostLogger::init();
113        // If this is the first init, max_level should be Info
114        // If logger was already set, this is still valid
115    }
116
117    #[test]
118    fn map_level_to_host() {
119        assert_eq!(map_to_host(Level::Error), 2);
120        assert_eq!(map_to_host(Level::Warn), 1);
121        assert_eq!(map_to_host(Level::Info), 0);
122        assert_eq!(map_to_host(Level::Debug), -1);
123        assert_eq!(map_to_host(Level::Trace), -2);
124    }
125
126    #[test]
127    fn test_log_truncation_marker() {
128        // Compose a message that will overflow the buffer
129        let long_msg = "A".repeat(3000);
130        memory::with_buffer(|buf| {
131            let written = super::format_log_message(buf, &format_args!("{}", long_msg));
132            let slice = buf.as_subslice(written);
133            assert_eq!(slice.len(), buf.capacity(), "Truncated log should fill the buffer");
134            assert!(slice.ends_with(TRUNC_MARKER), "Log message should end with truncation marker");
135        });
136    }
137
138    #[test]
139    fn test_format_log_message() {
140        let msg = "Test";
141        memory::with_buffer(|buf| {
142            let written = super::format_log_message(buf, &format_args!("{}", msg));
143            assert_eq!(written, msg.len(), "message should not be truncated");
144            assert_eq!(buf.as_subslice(written), msg.as_bytes());
145        });
146    }
147
148    #[test]
149    fn test_format_log_message_limit() {
150        let msg = "A".repeat(2048);
151        memory::with_buffer(|buf| {
152            let written = super::format_log_message(buf, &format_args!("{}", msg));
153            assert_eq!(written, msg.len(), "message should not be truncated");
154            assert_eq!(buf.as_subslice(written), msg.as_bytes());
155        });
156    }
157    #[test]
158    fn host_logger_enabled_within_max_level() {
159        // Set max level to Info
160        log::set_max_level(LevelFilter::Info);
161        let metadata = log::Metadata::builder().level(Level::Info).target("test").build();
162        assert!(LOGGER.enabled(&metadata));
163    }
164
165    #[test]
166    fn host_logger_enabled_below_max_level() {
167        log::set_max_level(LevelFilter::Info);
168        let metadata = log::Metadata::builder().level(Level::Error).target("test").build();
169        // Error is more severe than Info, so it should be enabled
170        assert!(LOGGER.enabled(&metadata));
171    }
172
173    #[test]
174    fn host_logger_disabled_above_max_level() {
175        log::set_max_level(LevelFilter::Warn);
176        let metadata = log::Metadata::builder().level(Level::Debug).target("test").build();
177        // Debug is less severe than Warn, so it should be disabled
178        assert!(!LOGGER.enabled(&metadata));
179    }
180
181    #[test]
182    fn host_logger_flush() {
183        // Flush is a no-op, should not panic
184        LOGGER.flush();
185    }
186
187    #[test]
188    fn test_max_level_enabled() {
189        // When host has the level enabled, it should return that level
190        let level = max_level(LevelFilter::Info);
191        // Info maps to host level 0, which is enabled in mock
192        assert_eq!(level, LevelFilter::Info);
193    }
194
195    #[test]
196    fn test_max_level_off_stays_off() {
197        // Off is the terminal state for level reduction and should return immediately.
198        assert_eq!(max_level(LevelFilter::Off), LevelFilter::Off);
199    }
200
201    #[test]
202    fn test_max_level_returns_off_when_host_disables_everything() {
203        let level = max_level_with(LevelFilter::Trace, |_| false);
204        assert_eq!(level, LevelFilter::Off);
205    }
206
207    #[test]
208    fn test_max_level_stops_at_first_enabled_level() {
209        let level = max_level_with(LevelFilter::Trace, |level| matches!(level, Level::Warn | Level::Error));
210        assert_eq!(level, LevelFilter::Warn);
211    }
212
213    #[test]
214    fn host_logger_log_direct_call() {
215        // Set max level high enough to allow Info messages
216        log::set_max_level(LevelFilter::Info);
217
218        // Create a log record directly and call LOGGER.log()
219        let record = log::Record::builder().level(Level::Info).target("test").args(format_args!("direct log test")).build();
220
221        // This should call handler::log internally
222        LOGGER.log(&record);
223    }
224
225    #[test]
226    fn host_logger_log_skips_disabled_level() {
227        // Set max level to Error only
228        log::set_max_level(LevelFilter::Error);
229
230        // Create a Debug record which should be filtered out
231        let record =
232            log::Record::builder().level(Level::Debug).target("test").args(format_args!("this should be skipped")).build();
233
234        // This should return early without calling handler::log
235        LOGGER.log(&record);
236    }
237
238    #[test]
239    fn test_max_level_decrement_until_enabled() {
240        // Set max level to Warn
241        log::set_max_level(LevelFilter::Warn);
242        // Call max_level with disabled level, which should decrement to Warn
243        let result = max_level(LevelFilter::Warn);
244        assert_eq!(result, LevelFilter::Warn, "max_level should decrement to Warn when only Warn is enabled on host");
245    }
246}