Skip to main content

nautilus_network/
logging.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
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
16use std::fmt::Debug;
17
18use nautilus_core::string::secret::{REDACTED, SecretString};
19use url::Url;
20
21/// A connection URL whose debug output contains only its HTTP or WebSocket origin.
22///
23/// Stores the original value without validation or normalization. Debug output omits user
24/// information, paths, queries, and fragments; invalid URLs and other schemes are redacted.
25/// Use only when the origin is public; use [`SecretString`] when the whole location is secret.
26#[derive(Clone, PartialEq, Eq)]
27pub struct ConnectionUrl(SecretString);
28
29impl ConnectionUrl {
30    /// Returns the original URL, which can contain credentials.
31    ///
32    /// Use [`Self::origin`] for logging.
33    #[must_use]
34    pub fn expose(&self) -> &str {
35        self.0.expose_secret()
36    }
37
38    /// Returns the URL origin for logging, or [`REDACTED`] for invalid URLs and other schemes.
39    #[must_use]
40    pub fn origin(&self) -> String {
41        url_origin(self.expose())
42    }
43}
44
45impl From<String> for ConnectionUrl {
46    fn from(value: String) -> Self {
47        Self(value.into())
48    }
49}
50
51impl From<&str> for ConnectionUrl {
52    fn from(value: &str) -> Self {
53        Self(value.into())
54    }
55}
56
57impl Debug for ConnectionUrl {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        self.origin().fmt(f)
60    }
61}
62
63/// Returns the scheme, host, and non-default port of an HTTP or WebSocket URL for logging.
64///
65/// Omits user information, paths, queries, and fragments, which can contain credentials.
66/// Returns [`REDACTED`] for invalid URLs and other schemes. Hosts use their ASCII serialization.
67#[must_use]
68pub fn url_origin(value: &str) -> String {
69    match Url::parse(value) {
70        Ok(url) if matches!(url.scheme(), "http" | "https" | "ws" | "wss") => {
71            url.origin().ascii_serialization()
72        }
73        _ => REDACTED.to_string(),
74    }
75}
76
77/// Logs that a task has started using `log::debug!`.
78pub(crate) fn log_task_started(task_name: &str) {
79    log::debug!("Started task '{task_name}'");
80}
81
82/// Logs that a task has stopped using `log::debug!`.
83pub(crate) fn log_task_stopped(task_name: &str) {
84    log::debug!("Stopped task '{task_name}'");
85}
86
87/// Logs that a task was aborted using `log::debug!`.
88pub(crate) fn log_task_aborted(task_name: &str) {
89    log::debug!("Aborted task '{task_name}'");
90}
91
92#[cfg(test)]
93mod url_origin_tests {
94    use rstest::rstest;
95
96    use super::*;
97
98    #[rstest]
99    #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
100    #[case("https://api.example.com:443/v1", "https://api.example.com")]
101    #[case(
102        "ws://user:password@example.com:8080/path-key?token=query-key#fragment-key",
103        "ws://example.com:8080"
104    )]
105    #[case(
106        "https://user%40secret:password%3Asecret@example.com/key%2Fsecret?api_key=secret",
107        "https://example.com"
108    )]
109    #[case(
110        "http://user:secret@[::1]:8123/path-secret?key=secret",
111        "http://[::1]:8123"
112    )]
113    #[case("https://bücher.example/path-secret", "https://xn--bcher-kva.example")]
114    #[case("wss://example.com:invalid/secret", REDACTED)]
115    #[case("invalid-secret", REDACTED)]
116    #[case("file:///secret", REDACTED)]
117    #[case("blob:https://example.com/secret", REDACTED)]
118    fn url_origin_preserves_endpoint_without_credentials(
119        #[case] input: &str,
120        #[case] expected: &str,
121    ) {
122        let url = ConnectionUrl::from(input);
123
124        assert_eq!(url_origin(input), expected);
125        assert_eq!(url.origin(), expected);
126        assert_eq!(format!("{url:?}"), format!("{expected:?}"));
127        assert_eq!(ConnectionUrl::from(input.to_owned()), url);
128        let cloned = url.clone();
129        assert_eq!(url.expose(), input);
130        drop(url);
131        assert_eq!(cloned.expose(), input);
132    }
133}
134
135#[cfg(test)]
136#[cfg(not(all(feature = "simulation", madsim)))]
137#[cfg(target_os = "linux")]
138pub(crate) mod tests {
139    use std::sync::Once;
140
141    use log::{Level, LevelFilter, Log, Metadata, Record};
142    use parking_lot::Mutex;
143
144    struct CaptureState {
145        targets: &'static [&'static str],
146        messages: Vec<(Level, String)>,
147    }
148
149    struct CapturingLogger {
150        state: Mutex<CaptureState>,
151    }
152
153    impl CapturingLogger {
154        fn clear(&self, targets: &'static [&'static str]) {
155            let mut state = self.state.lock();
156            state.targets = targets;
157            state.messages.clear();
158        }
159
160        fn messages(&self) -> Vec<(Level, String)> {
161            self.state.lock().messages.clone()
162        }
163    }
164
165    impl Log for CapturingLogger {
166        fn enabled(&self, metadata: &Metadata<'_>) -> bool {
167            metadata.level() <= Level::Trace
168        }
169
170        fn log(&self, record: &Record<'_>) {
171            if self.enabled(record.metadata()) {
172                let mut state = self.state.lock();
173                if state.targets.is_empty() || state.targets.contains(&record.target()) {
174                    state
175                        .messages
176                        .push((record.level(), record.args().to_string()));
177                }
178            }
179        }
180
181        fn flush(&self) {}
182    }
183
184    static CAPTURING_LOGGER: CapturingLogger = CapturingLogger {
185        state: Mutex::new(CaptureState {
186            targets: &[],
187            messages: Vec::new(),
188        }),
189    };
190    static CAPTURE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
191    static INSTALL_LOGGER: Once = Once::new();
192
193    pub(crate) struct LogCapture {
194        logger: &'static CapturingLogger,
195        _guard: tokio::sync::MutexGuard<'static, ()>,
196    }
197
198    impl LogCapture {
199        pub(crate) fn messages(&self) -> Vec<(Level, String)> {
200            self.logger.messages()
201        }
202    }
203
204    pub(crate) async fn capture_logs() -> LogCapture {
205        capture_logs_for(&[]).await
206    }
207
208    pub(crate) async fn capture_logs_for(targets: &'static [&'static str]) -> LogCapture {
209        let guard = CAPTURE_LOCK.lock().await;
210        INSTALL_LOGGER.call_once(|| {
211            log::set_logger(&CAPTURING_LOGGER).expect("test logger already installed");
212        });
213        log::set_max_level(LevelFilter::Trace);
214        CAPTURING_LOGGER.clear(targets);
215        LogCapture {
216            logger: &CAPTURING_LOGGER,
217            _guard: guard,
218        }
219    }
220}