Skip to main content

AirLibrary/
DevLog.rs

1//! # DevLog - Tag-filtered development logging
2//!
3//! Controlled by `Trace` environment variable.
4//! The same tags work in Mountain (Rust), Air (Rust), Wind/Sky (TypeScript).
5//!
6//! ## Usage
7//! ```bash
8//! Trace=lifecycle,grpc ./Air          # only lifecycle + gRPC
9//! Trace=all ./Air                     # everything
10//! Trace=short ./Air                   # everything, compressed + deduped
11//! Trace=indexing,http ./Air           # indexing + HTTP
12//! ./Air                                      # nothing (silent daemon)
13//! ```
14//!
15//! ## Short Mode
16//!
17//! `Trace=short` enables all tags but compresses output:
18//! - Long app-data paths aliased to `$APP`
19//! - Consecutive duplicate messages counted (`(x14)` suffix)
20//! - Rust log targets compressed (`D::Binary::Main::Entry` → `Entry`)
21//!
22//! ## Tags (Air daemon tags)
23//!
24//! | Tag           | Scope                                               |
25//! |---------------|-----------------------------------------------------|
26//! | `vfs`         | File stat, read, write, readdir, mkdir, delete, copy|
27//! | `ipc`         | IPC routing: invoke dispatch, channel calls          |
28//! | `config`      | Configuration get/set, env paths, workbench config   |
29//! | `lifecycle`   | Startup, shutdown, phases, window events             |
30//! | `storage`     | Storage get/set/delete, items, optimize              |
31//! | `extensions`  | Extension scanning, activation, management           |
32//! | `update`      | Update service: check, download, apply               |
33//! | `grpc`        | gRPC/Vine: server, client, connections               |
34//! | `indexing`    | File system indexing, symbol extraction, watching    |
35//! | `http`        | HTTP client requests, responses, retries             |
36//! | `daemon`      | Daemon lifecycle, lock management, signals           |
37//! | `security`    | Rate limiting, checksums, secure storage             |
38//! | `metrics`     | Prometheus-style metrics collection                  |
39//! | `air`         | Air tracing, telemetry, OTLP emission                |
40//! | `resilience`  | Retry policies, circuit breakers, timeouts           |
41//! | `bootstrap`   | Effect-TS bootstrap stages                           |
42
43use std::sync::{Mutex, OnceLock};
44
45static ENABLED_TAGS:OnceLock<Vec<String>> = OnceLock::new();
46
47static SHORT_MODE:OnceLock<bool> = OnceLock::new();
48
49// ── Path alias ──────────────────────────────────────────────────────────
50// The app-data directory name is absurdly long. In short mode, alias it.
51static APP_DATA_PREFIX:OnceLock<Option<String>> = OnceLock::new();
52
53fn DetectAppDataPrefix() -> Option<String> {
54	// Match the bundle identifier pattern used by Air/Mountain
55	if let Ok(Home) = std::env::var("HOME") {
56		let Base = format!("{}/Library/Application Support", Home);
57
58		if let Ok(Entries) = std::fs::read_dir(&Base) {
59			for Entry in Entries.flatten() {
60				let Name = Entry.file_name();
61
62				let Name = Name.to_string_lossy();
63
64				if Name.starts_with("land.editor.") && Name.contains("mountain") {
65					return Some(format!("{}/{}", Base, Name));
66				}
67			}
68		}
69	}
70
71	None
72}
73
74/// Get the app-data path prefix for aliasing (cached).
75pub fn AppDataPrefix() -> &'static Option<String> { APP_DATA_PREFIX.get_or_init(DetectAppDataPrefix) }
76
77/// Replace the long app-data path with `$APP` in a string.
78pub fn AliasPath(Input:&str) -> String {
79	if let Some(Prefix) = AppDataPrefix() {
80		Input.replace(Prefix.as_str(), "$APP")
81	} else {
82		Input.to_string()
83	}
84}
85
86// ── Dedup buffer ────────────────────────────────────────────────────────
87
88pub struct DedupState {
89	pub LastKey:String,
90
91	pub Count:u64,
92}
93
94pub static DEDUP:Mutex<DedupState> = Mutex::new(DedupState { LastKey:String::new(), Count:0 });
95
96/// Flush the dedup buffer - prints the pending count if > 1.
97pub fn FlushDedup() {
98	if let Ok(mut State) = DEDUP.lock() {
99		if State.Count > 1 {
100			eprintln!("  (x{})", State.Count);
101		}
102
103		State.LastKey.clear();
104
105		State.Count = 0;
106	}
107}
108
109// ── Tag resolution ──────────────────────────────────────────────────────
110
111fn EnabledTags() -> &'static Vec<String> {
112	ENABLED_TAGS.get_or_init(|| {
113		match std::env::var("Trace") {
114			Ok(Val) => Val.split(',').map(|S| S.trim().to_lowercase()).collect(),
115			Err(_) => vec![],
116		}
117	})
118}
119
120/// Whether `Trace=short` is active.
121pub fn IsShort() -> bool { *SHORT_MODE.get_or_init(|| EnabledTags().iter().any(|T| T == "short")) }
122
123/// Check if a tag is enabled.
124pub fn IsEnabled(Tag:&str) -> bool {
125	let Tags = EnabledTags();
126
127	if Tags.is_empty() {
128		return false;
129	}
130
131	let Lower = Tag.to_lowercase();
132
133	Tags.iter().any(|T| T == "all" || T == "short" || T == Lower.as_str())
134}
135
136/// Log a tagged dev message. Only prints if the tag is enabled via
137/// Trace.
138///
139/// In `short` mode: aliases long paths, deduplicates consecutive identical
140/// lines.
141#[macro_export]
142macro_rules! dev_log {
143
144	($Tag:expr, $($Arg:tt)*) => {
145
146		if $crate::DevLog::IsEnabled($Tag) {
147
148			let RawMessage = format!($($Arg)*);
149
150			let TagUpper = $Tag.to_uppercase();
151
152			if $crate::DevLog::IsShort() {
153
154				let Aliased = $crate::DevLog::AliasPath(&RawMessage);
155
156				let Key = format!("{}:{}", TagUpper, Aliased);
157
158				let ShouldPrint = {
159
160					if let Ok(mut State) = $crate::DevLog::DEDUP.lock() {
161
162						if State.LastKey == Key {
163
164							State.Count += 1;
165
166							false
167						} else {
168
169							let PrevCount = State.Count;
170
171							let HadPrev = !State.LastKey.is_empty();
172
173							State.LastKey = Key;
174
175							State.Count = 1;
176
177							if HadPrev && PrevCount > 1 {
178
179								eprintln!("  (x{})", PrevCount);
180							}
181
182							true
183						}
184					} else {
185
186						true
187					}
188				};
189
190				if ShouldPrint {
191
192					eprintln!("[DEV:{}] {}", TagUpper, Aliased);
193				}
194			} else {
195
196				eprintln!("[DEV:{}] {}", TagUpper, RawMessage);
197			}
198		}
199	};
200}
201
202// ============================================================================
203// OTLP Span Emission - sends spans directly to Jaeger/OTEL collector
204// ============================================================================
205
206use std::{
207	sync::atomic::{AtomicBool, Ordering},
208	time::{SystemTime, UNIX_EPOCH},
209};
210
211static OTLP_AVAILABLE:AtomicBool = AtomicBool::new(true);
212
213static OTLP_TRACE_ID:OnceLock<String> = OnceLock::new();
214
215fn GetTraceId() -> &'static str {
216	OTLP_TRACE_ID.get_or_init(|| {
217		use std::{
218			collections::hash_map::DefaultHasher,
219			hash::{Hash, Hasher},
220		};
221		let mut H = DefaultHasher::new();
222		std::process::id().hash(&mut H);
223		SystemTime::now()
224			.duration_since(UNIX_EPOCH)
225			.unwrap_or_default()
226			.as_nanos()
227			.hash(&mut H);
228		format!("{:032x}", H.finish() as u128)
229	})
230}
231
232pub fn NowNano() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64 }
233
234/// Emit an OTLP span to the local collector (Jaeger at 127.0.0.1:4318).
235/// Fire-and-forget on a background thread. Stops trying after first failure.
236pub fn EmitOTLPSpan(Name:&str, StartNano:u64, EndNano:u64, Attributes:&[(&str, &str)]) {
237	if !cfg!(debug_assertions) {
238		return;
239	}
240
241	if !OTLP_AVAILABLE.load(Ordering::Relaxed) {
242		return;
243	}
244
245	let SpanId = format!("{:016x}", rand_u64());
246
247	let TraceId = GetTraceId().to_string();
248
249	let SpanName = Name.to_string();
250
251	let AttributesJson:Vec<String> = Attributes
252		.iter()
253		.map(|(K, V)| {
254			format!(
255				r#"{{"key":"{}","value":{{"stringValue":"{}"}}}}"#,
256				K,
257				V.replace('\\', "\\\\").replace('"', "\\\"")
258			)
259		})
260		.collect();
261
262	let IsError = SpanName.contains("error");
263
264	let StatusCode = if IsError { 2 } else { 1 };
265
266	let Payload = format!(
267		concat!(
268			r#"{{"resourceSpans":[{{"resource":{{"attributes":["#,
269			r#"{{"key":"service.name","value":{{"stringValue":"land-editor-air"}}}},"#,
270			r#"{{"key":"service.version","value":{{"stringValue":"0.0.1"}}}}"#,
271			r#"]}},"scopeSpans":[{{"scope":{{"name":"air.daemon","version":"1.0.0"}},"#,
272			r#""spans":[{{"traceId":"{}","spanId":"{}","name":"{}","kind":1,"#,
273			r#""startTimeUnixNano":"{}","endTimeUnixNano":"{}","#,
274			r#""attributes":[{}],"status":{{"code":{}}}}}]}}]}}]}}"#,
275		),
276		TraceId,
277		SpanId,
278		SpanName,
279		StartNano,
280		EndNano,
281		AttributesJson.join(","),
282		StatusCode,
283	);
284
285	// Fire-and-forget on a background thread
286	std::thread::spawn(move || {
287		use std::{
288			io::{Read as IoRead, Write as IoWrite},
289			net::TcpStream,
290			time::Duration,
291		};
292
293		let Ok(mut Stream) = TcpStream::connect_timeout(&"127.0.0.1:4318".parse().unwrap(), Duration::from_millis(200))
294		else {
295			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
296			return;
297		};
298		let _ = Stream.set_write_timeout(Some(Duration::from_millis(200)));
299		let _ = Stream.set_read_timeout(Some(Duration::from_millis(200)));
300
301		let HttpReq = format!(
302			"POST /v1/traces HTTP/1.1\r\nHost: 127.0.0.1:4318\r\nContent-Type: application/json\r\nContent-Length: \
303			 {}\r\nConnection: close\r\n\r\n",
304			Payload.len()
305		);
306		if Stream.write_all(HttpReq.as_bytes()).is_err() {
307			return;
308		}
309		if Stream.write_all(Payload.as_bytes()).is_err() {
310			return;
311		}
312		let mut Buf = [0u8; 32];
313		let _ = Stream.read(&mut Buf);
314		if !(Buf.starts_with(b"HTTP/1.1 2") || Buf.starts_with(b"HTTP/1.0 2")) {
315			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
316		}
317	});
318}
319
320fn rand_u64() -> u64 {
321	use std::{
322		collections::hash_map::DefaultHasher,
323		hash::{Hash, Hasher},
324	};
325
326	let mut H = DefaultHasher::new();
327
328	std::thread::current().id().hash(&mut H);
329
330	NowNano().hash(&mut H);
331
332	H.finish()
333}
334
335/// Convenience macro: emit an OTLP span for an Air daemon handler.
336/// Usage: `otel_span!("air:indexFile", StartNano, &[("path", &SomePath)]);`
337#[macro_export]
338macro_rules! otel_span {
339	($Name:expr, $Start:expr, $Attrs:expr) => {
340		$crate::DevLog::EmitOTLPSpan($Name, $Start, $crate::DevLog::NowNano(), $Attrs)
341	};
342
343	($Name:expr, $Start:expr) => {
344		$crate::DevLog::EmitOTLPSpan($Name, $Start, $crate::DevLog::NowNano(), &[])
345	};
346}