Skip to main content

moq_native/
log.rs

1use serde::{Deserialize, Serialize};
2use serde_with::DisplayFromStr;
3use tracing::Level;
4use tracing::level_filters::LevelFilter;
5use tracing_subscriber::EnvFilter;
6use tracing_subscriber::Layer;
7use tracing_subscriber::layer::SubscriberExt;
8use tracing_subscriber::util::SubscriberInitExt;
9use url::Url;
10
11/// Tracing log configuration.
12#[serde_with::serde_as]
13#[derive(Clone, clap::Parser, Serialize, Deserialize, Debug)]
14#[serde(deny_unknown_fields, default)]
15#[non_exhaustive]
16pub struct Log {
17	/// The level filter to use.
18	#[serde_as(as = "DisplayFromStr")]
19	#[arg(id = "log-level", long = "log-level", default_value = "info", env = "MOQ_LOG_LEVEL")]
20	pub level: Level,
21}
22
23impl Default for Log {
24	fn default() -> Self {
25		Self { level: Level::INFO }
26	}
27}
28
29impl Log {
30	/// Log at the given level and below.
31	pub fn new(level: Level) -> Self {
32		Self { level }
33	}
34
35	/// The configured level as a filter.
36	pub fn level(&self) -> LevelFilter {
37		LevelFilter::from_level(self.level)
38	}
39
40	/// Install this as the process-wide tracing subscriber.
41	///
42	/// `RUST_LOG` overrides the configured level. Logs go to stderr, or to
43	/// logcat on Android. Errors if a subscriber is already installed, so call
44	/// it once at startup.
45	pub fn init(&self) -> crate::Result<()> {
46		let filter = EnvFilter::builder()
47			.with_default_directive(self.level().into()) // Default to our -q/-v args
48			.from_env_lossy() // Allow overriding with RUST_LOG
49			.add_directive("h2=warn".parse()?)
50			.add_directive("quinn=info".parse()?)
51			.add_directive("noq=info".parse()?)
52			.add_directive("tungstenite=info".parse()?)
53			.add_directive("rustls=info".parse()?)
54			.add_directive("tracing::span=off".parse()?)
55			.add_directive("tracing::span::active=off".parse()?)
56			.add_directive("tokio=info".parse()?)
57			.add_directive("runtime=info".parse()?);
58
59		let registry = tracing_subscriber::registry();
60
61		// On Android, route logs to logcat so they can be inspected via ADB/Android Studio.
62		// Everywhere else, format to stderr.
63		#[cfg(all(target_os = "android", feature = "android-logcat"))]
64		let registry = {
65			let logcat_layer = tracing_android::layer("MoQNative")
66				.map_err(|e| crate::Error::Logcat(std::sync::Arc::new(e)))?
67				.with_filter(filter);
68			registry.with(logcat_layer)
69		};
70
71		#[cfg(not(all(target_os = "android", feature = "android-logcat")))]
72		let registry = {
73			let fmt_layer = tracing_subscriber::fmt::layer()
74				.with_writer(std::io::stderr)
75				.with_filter(filter);
76			registry.with(fmt_layer)
77		};
78
79		registry
80			.try_init()
81			.map_err(|e| crate::Error::SetSubscriber(std::sync::Arc::new(e)))?;
82
83		Ok(())
84	}
85}
86
87/// A URL rendered without its credentials, for logging.
88///
89/// A relay URL routinely carries an auth token in its query (`?jwt=...`), and any
90/// URL may carry HTTP userinfo (`https://user:pass@host/`). [`Display`] prints only
91/// the scheme, host, port, and path, so wrap every URL headed for a log line.
92///
93/// ```
94/// # use moq_native::RedactedUrl;
95/// let url = url::Url::parse("https://user:pass@relay.example.com/anon/demo?jwt=secret").unwrap();
96/// assert_eq!(RedactedUrl::new(&url).to_string(), "https://relay.example.com/anon/demo");
97/// ```
98///
99/// [`Display`]: std::fmt::Display
100#[derive(Clone, Copy)]
101pub struct RedactedUrl<'a>(&'a Url);
102
103impl<'a> RedactedUrl<'a> {
104	/// Borrow `url` for redacted display.
105	pub fn new(url: &'a Url) -> Self {
106		Self(url)
107	}
108}
109
110/// Delegates to [`Display`](std::fmt::Display) so `?redacted` in a log macro can't
111/// undo the redaction a derived impl would have printed straight through.
112impl std::fmt::Debug for RedactedUrl<'_> {
113	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114		write!(f, "{self}")
115	}
116}
117
118impl std::fmt::Display for RedactedUrl<'_> {
119	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120		write!(f, "{}://", self.0.scheme())?;
121
122		// `host_str` brackets an IPv6 literal and `port` is `None` for the scheme's
123		// default, so both render the way `Url`'s own `Display` would.
124		if let Some(host) = self.0.host_str() {
125			f.write_str(host)?;
126			if let Some(port) = self.0.port() {
127				write!(f, ":{port}")?;
128			}
129		}
130
131		f.write_str(self.0.path())
132	}
133}
134
135#[cfg(test)]
136mod tests {
137	use super::RedactedUrl;
138	use url::Url;
139
140	fn redact(url: &str) -> String {
141		RedactedUrl::new(&Url::parse(url).unwrap()).to_string()
142	}
143
144	#[test]
145	fn drops_query_and_userinfo() {
146		let rendered = redact("https://user:pass@relay.example.com/anon/demo?jwt=secret#frag");
147		assert_eq!(rendered, "https://relay.example.com/anon/demo");
148		for secret in ["jwt", "secret", "user", "pass", "frag"] {
149			assert!(!rendered.contains(secret), "{rendered} leaked {secret}");
150		}
151	}
152
153	#[test]
154	fn debug_matches_display() {
155		let url = Url::parse("https://user:pass@relay.example.com/anon/demo?jwt=secret").unwrap();
156		let redacted = RedactedUrl::new(&url);
157		assert_eq!(format!("{redacted:?}"), redacted.to_string());
158	}
159
160	#[test]
161	fn keeps_the_dial_target() {
162		assert_eq!(redact("https://relay.example.com/"), "https://relay.example.com/");
163		assert_eq!(
164			redact("tcp://relay.example.com:4443/anon"),
165			"tcp://relay.example.com:4443/anon"
166		);
167		assert_eq!(redact("https://[::1]:8443/anon"), "https://[::1]:8443/anon");
168		assert_eq!(redact("unix:///run/moq/internal.sock"), "unix:///run/moq/internal.sock");
169	}
170}