Skip to main content

AirLibrary/
DevLog.rs

1//! # DevLog - Tag-filtered development logging
2//!
3//! Controlled by `LAND_DEV_LOG` environment variable.
4//! The same tags work in Mountain (Rust), Air (Rust), Wind/Sky (TypeScript).
5//!
6//! ## Usage
7//! ```bash
8//! LAND_DEV_LOG=lifecycle,grpc ./Air          # only lifecycle + gRPC
9//! LAND_DEV_LOG=all ./Air                     # everything
10//! LAND_DEV_LOG=short ./Air                   # everything, compressed + deduped
11//! LAND_DEV_LOG=indexing,http ./Air           # indexing + HTTP
12//! ./Air                                      # nothing (silent daemon)
13//! ```
14//!
15//! ## Short Mode
16//!
17//! `LAND_DEV_LOG=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();
46static SHORT_MODE:OnceLock<bool> = OnceLock::new();
47
48// ── Path alias ──────────────────────────────────────────────────────────
49// The app-data directory name is absurdly long. In short mode, alias it.
50static APP_DATA_PREFIX:OnceLock<Option<String>> = OnceLock::new();
51
52fn DetectAppDataPrefix() -> Option<String> {
53	// Match the bundle identifier pattern used by Air/Mountain
54	if let Ok(Home) = std::env::var("HOME") {
55		let Base = format!("{}/Library/Application Support", Home);
56		if let Ok(Entries) = std::fs::read_dir(&Base) {
57			for Entry in Entries.flatten() {
58				let Name = Entry.file_name();
59				let Name = Name.to_string_lossy();
60				if Name.starts_with("land.editor.") && Name.contains("mountain") {
61					return Some(format!("{}/{}", Base, Name));
62				}
63			}
64		}
65	}
66	None
67}
68
69/// Get the app-data path prefix for aliasing (cached).
70pub fn AppDataPrefix() -> &'static Option<String> { APP_DATA_PREFIX.get_or_init(DetectAppDataPrefix) }
71
72/// Replace the long app-data path with `$APP` in a string.
73pub fn AliasPath(Input:&str) -> String {
74	if let Some(Prefix) = AppDataPrefix() {
75		Input.replace(Prefix.as_str(), "$APP")
76	} else {
77		Input.to_string()
78	}
79}
80
81// ── Dedup buffer ────────────────────────────────────────────────────────
82
83pub struct DedupState {
84	pub LastKey:String,
85	pub Count:u64,
86}
87
88pub static DEDUP:Mutex<DedupState> = Mutex::new(DedupState { LastKey:String::new(), Count:0 });
89
90/// Flush the dedup buffer - prints the pending count if > 1.
91pub fn FlushDedup() {
92	if let Ok(mut State) = DEDUP.lock() {
93		if State.Count > 1 {
94			eprintln!("  (x{})", State.Count);
95		}
96		State.LastKey.clear();
97		State.Count = 0;
98	}
99}
100
101// ── Tag resolution ──────────────────────────────────────────────────────
102
103fn EnabledTags() -> &'static Vec<String> {
104	ENABLED_TAGS.get_or_init(|| {
105		match std::env::var("LAND_DEV_LOG") {
106			Ok(Val) => Val.split(',').map(|S| S.trim().to_lowercase()).collect(),
107			Err(_) => vec![],
108		}
109	})
110}
111
112/// Whether `LAND_DEV_LOG=short` is active.
113pub fn IsShort() -> bool { *SHORT_MODE.get_or_init(|| EnabledTags().iter().any(|T| T == "short")) }
114
115/// Check if a tag is enabled.
116pub fn IsEnabled(Tag:&str) -> bool {
117	let Tags = EnabledTags();
118	if Tags.is_empty() {
119		return false;
120	}
121	let Lower = Tag.to_lowercase();
122	Tags.iter().any(|T| T == "all" || T == "short" || T == Lower.as_str())
123}
124
125/// Log a tagged dev message. Only prints if the tag is enabled via
126/// LAND_DEV_LOG.
127///
128/// In `short` mode: aliases long paths, deduplicates consecutive identical
129/// lines.
130#[macro_export]
131macro_rules! dev_log {
132	($Tag:expr, $($Arg:tt)*) => {
133		if $crate::DevLog::IsEnabled($Tag) {
134			let RawMessage = format!($($Arg)*);
135			let TagUpper = $Tag.to_uppercase();
136			if $crate::DevLog::IsShort() {
137				let Aliased = $crate::DevLog::AliasPath(&RawMessage);
138				let Key = format!("{}:{}", TagUpper, Aliased);
139				let ShouldPrint = {
140					if let Ok(mut State) = $crate::DevLog::DEDUP.lock() {
141						if State.LastKey == Key {
142							State.Count += 1;
143							false
144						} else {
145							let PrevCount = State.Count;
146							let HadPrev = !State.LastKey.is_empty();
147							State.LastKey = Key;
148							State.Count = 1;
149							if HadPrev && PrevCount > 1 {
150								eprintln!("  (x{})", PrevCount);
151							}
152							true
153						}
154					} else {
155						true
156					}
157				};
158				if ShouldPrint {
159					eprintln!("[DEV:{}] {}", TagUpper, Aliased);
160				}
161			} else {
162				eprintln!("[DEV:{}] {}", TagUpper, RawMessage);
163			}
164		}
165	};
166}
167
168// ============================================================================
169// OTLP Span Emission — sends spans directly to Jaeger/OTEL collector
170// ============================================================================
171
172use std::{
173	sync::atomic::{AtomicBool, Ordering},
174	time::{SystemTime, UNIX_EPOCH},
175};
176
177static OTLP_AVAILABLE:AtomicBool = AtomicBool::new(true);
178static OTLP_TRACE_ID:OnceLock<String> = OnceLock::new();
179
180fn GetTraceId() -> &'static str {
181	OTLP_TRACE_ID.get_or_init(|| {
182		use std::{
183			collections::hash_map::DefaultHasher,
184			hash::{Hash, Hasher},
185		};
186		let mut H = DefaultHasher::new();
187		std::process::id().hash(&mut H);
188		SystemTime::now()
189			.duration_since(UNIX_EPOCH)
190			.unwrap_or_default()
191			.as_nanos()
192			.hash(&mut H);
193		format!("{:032x}", H.finish() as u128)
194	})
195}
196
197pub fn NowNano() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64 }
198
199/// Emit an OTLP span to the local collector (Jaeger at 127.0.0.1:4318).
200/// Fire-and-forget on a background thread. Stops trying after first failure.
201pub fn EmitOTLPSpan(Name:&str, StartNano:u64, EndNano:u64, Attributes:&[(&str, &str)]) {
202	if !cfg!(debug_assertions) {
203		return;
204	}
205	if !OTLP_AVAILABLE.load(Ordering::Relaxed) {
206		return;
207	}
208
209	let SpanId = format!("{:016x}", rand_u64());
210	let TraceId = GetTraceId().to_string();
211	let SpanName = Name.to_string();
212
213	let AttributesJson:Vec<String> = Attributes
214		.iter()
215		.map(|(K, V)| {
216			format!(
217				r#"{{"key":"{}","value":{{"stringValue":"{}"}}}}"#,
218				K,
219				V.replace('\\', "\\\\").replace('"', "\\\"")
220			)
221		})
222		.collect();
223
224	let IsError = SpanName.contains("error");
225
226	let StatusCode = if IsError { 2 } else { 1 };
227	let Payload = format!(
228		concat!(
229			r#"{{"resourceSpans":[{{"resource":{{"attributes":["#,
230			r#"{{"key":"service.name","value":{{"stringValue":"land-editor-air"}}}},"#,
231			r#"{{"key":"service.version","value":{{"stringValue":"0.0.1"}}}}"#,
232			r#"]}},"scopeSpans":[{{"scope":{{"name":"air.daemon","version":"1.0.0"}},"#,
233			r#""spans":[{{"traceId":"{}","spanId":"{}","name":"{}","kind":1,"#,
234			r#""startTimeUnixNano":"{}","endTimeUnixNano":"{}","#,
235			r#""attributes":[{}],"status":{{"code":{}}}}}]}}]}}]}}"#,
236		),
237		TraceId,
238		SpanId,
239		SpanName,
240		StartNano,
241		EndNano,
242		AttributesJson.join(","),
243		StatusCode,
244	);
245
246	// Fire-and-forget on a background thread
247	std::thread::spawn(move || {
248		use std::{
249			io::{Read as IoRead, Write as IoWrite},
250			net::TcpStream,
251			time::Duration,
252		};
253
254		let Ok(mut Stream) = TcpStream::connect_timeout(&"127.0.0.1:4318".parse().unwrap(), Duration::from_millis(200))
255		else {
256			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
257			return;
258		};
259		let _ = Stream.set_write_timeout(Some(Duration::from_millis(200)));
260		let _ = Stream.set_read_timeout(Some(Duration::from_millis(200)));
261
262		let HttpReq = format!(
263			"POST /v1/traces HTTP/1.1\r\nHost: 127.0.0.1:4318\r\nContent-Type: application/json\r\nContent-Length: \
264			 {}\r\nConnection: close\r\n\r\n",
265			Payload.len()
266		);
267		if Stream.write_all(HttpReq.as_bytes()).is_err() {
268			return;
269		}
270		if Stream.write_all(Payload.as_bytes()).is_err() {
271			return;
272		}
273		let mut Buf = [0u8; 32];
274		let _ = Stream.read(&mut Buf);
275		if !(Buf.starts_with(b"HTTP/1.1 2") || Buf.starts_with(b"HTTP/1.0 2")) {
276			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
277		}
278	});
279}
280
281fn rand_u64() -> u64 {
282	use std::{
283		collections::hash_map::DefaultHasher,
284		hash::{Hash, Hasher},
285	};
286	let mut H = DefaultHasher::new();
287	std::thread::current().id().hash(&mut H);
288	NowNano().hash(&mut H);
289	H.finish()
290}
291
292/// Convenience macro: emit an OTLP span for an Air daemon handler.
293/// Usage: `otel_span!("air:indexFile", StartNano, &[("path", &SomePath)]);`
294#[macro_export]
295macro_rules! otel_span {
296	($Name:expr, $Start:expr, $Attrs:expr) => {
297		$crate::DevLog::EmitOTLPSpan($Name, $Start, $crate::DevLog::NowNano(), $Attrs)
298	};
299	($Name:expr, $Start:expr) => {
300		$crate::DevLog::EmitOTLPSpan($Name, $Start, $crate::DevLog::NowNano(), &[])
301	};
302}