Skip to main content

Grove/
DevLog.rs

1//! # DevLog - Tag-filtered development logging for Grove
2//!
3//! Controlled by `LAND_DEV_LOG` environment variable.
4//! The same tags work in both Mountain (Rust) and Wind/Sky (TypeScript).
5//!
6//! ## Usage
7//! ```bash
8//! LAND_DEV_LOG=grove,wasm ./Grove          # only grove + WASM
9//! LAND_DEV_LOG=all ./Grove                 # everything
10//! LAND_DEV_LOG=short ./Grove              # everything, compressed + deduped
11//! LAND_DEV_LOG=transport,grpc ./Grove     # transport + gRPC
12//! ./Grove                                  # nothing
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 (Grove-specific tags plus shared tags)
23//!
24//! | Tag           | Scope                                               |
25//! |---------------|-----------------------------------------------------|
26//! | `grove`       | Extension host lifecycle: create, start, kill, exit |
27//! | `wasm`        | WASM module loading, compilation, execution         |
28//! | `transport`   | IPC/gRPC/WASM transport strategy and routing        |
29//! | `grpc`        | gRPC/Vine: server, client, connections               |
30//! | `extensions`  | Extension scanning, activation, management           |
31//! | `lifecycle`   | Startup, shutdown, phases, window events             |
32//! | `config`      | Configuration get/set, env paths, workbench config   |
33//! | `ipc`         | IPC routing: invoke dispatch, channel calls          |
34//! | `vfs`         | File stat, read, write, readdir, mkdir, delete, copy|
35//! | `storage`     | Storage get/set/delete, items, optimize              |
36//! | `commands`    | Command registry: execute, getAll                    |
37//! | `exthost`     | Extension host: create, start, kill, exit info       |
38//! | `model`       | Text model: open, close, get, updateContent          |
39//! | `output`      | Output channels: create, append, show                |
40//! | `bootstrap`   | Effect-TS bootstrap stages                           |
41
42use std::sync::{Mutex, OnceLock};
43
44static ENABLED_TAGS:OnceLock<Vec<String>> = OnceLock::new();
45static SHORT_MODE:OnceLock<bool> = OnceLock::new();
46
47// ── Path alias ──────────────────────────────────────────────────────────
48// The app-data directory name is absurdly long. In short mode, alias it.
49static APP_DATA_PREFIX:OnceLock<Option<String>> = OnceLock::new();
50
51fn DetectAppDataPrefix() -> Option<String> {
52	// Match the bundle identifier pattern used by Mountain
53	if let Ok(Home) = std::env::var("HOME") {
54		let Base = format!("{}/Library/Application Support", Home);
55		if let Ok(Entries) = std::fs::read_dir(&Base) {
56			for Entry in Entries.flatten() {
57				let Name = Entry.file_name();
58				let Name = Name.to_string_lossy();
59				if Name.starts_with("land.editor.") && Name.contains("mountain") {
60					return Some(format!("{}/{}", Base, Name));
61				}
62			}
63		}
64	}
65	None
66}
67
68/// Get the app-data path prefix for aliasing (cached).
69pub fn AppDataPrefix() -> &'static Option<String> { APP_DATA_PREFIX.get_or_init(DetectAppDataPrefix) }
70
71/// Replace the long app-data path with `$APP` in a string.
72pub fn AliasPath(Input:&str) -> String {
73	if let Some(Prefix) = AppDataPrefix() {
74		Input.replace(Prefix.as_str(), "$APP")
75	} else {
76		Input.to_string()
77	}
78}
79
80// ── Dedup buffer ────────────────────────────────────────────────────────
81
82pub struct DedupState {
83	pub LastKey:String,
84	pub Count:u64,
85}
86
87pub static DEDUP:Mutex<DedupState> = Mutex::new(DedupState { LastKey:String::new(), Count:0 });
88
89/// Flush the dedup buffer - prints the pending count if > 1.
90pub fn FlushDedup() {
91	if let Ok(mut State) = DEDUP.lock() {
92		if State.Count > 1 {
93			eprintln!("  (x{})", State.Count);
94		}
95		State.LastKey.clear();
96		State.Count = 0;
97	}
98}
99
100// ── Tag resolution ──────────────────────────────────────────────────────
101
102fn EnabledTags() -> &'static Vec<String> {
103	ENABLED_TAGS.get_or_init(|| {
104		match std::env::var("LAND_DEV_LOG") {
105			Ok(Val) => Val.split(',').map(|S| S.trim().to_lowercase()).collect(),
106			Err(_) => vec![],
107		}
108	})
109}
110
111/// Whether `LAND_DEV_LOG=short` is active.
112pub fn IsShort() -> bool { *SHORT_MODE.get_or_init(|| EnabledTags().iter().any(|T| T == "short")) }
113
114/// Check if a tag is enabled.
115pub fn IsEnabled(Tag:&str) -> bool {
116	let Tags = EnabledTags();
117	if Tags.is_empty() {
118		return false;
119	}
120	let Lower = Tag.to_lowercase();
121	Tags.iter().any(|T| T == "all" || T == "short" || T == Lower.as_str())
122}
123
124/// Log a tagged dev message. Only prints if the tag is enabled via
125/// LAND_DEV_LOG.
126///
127/// In `short` mode: aliases long paths, deduplicates consecutive identical
128/// lines.
129#[macro_export]
130macro_rules! dev_log {
131	($Tag:expr, $($Arg:tt)*) => {
132		if $crate::DevLog::IsEnabled($Tag) {
133			let RawMessage = format!($($Arg)*);
134			let TagUpper = $Tag.to_uppercase();
135			if $crate::DevLog::IsShort() {
136				let Aliased = $crate::DevLog::AliasPath(&RawMessage);
137				let Key = format!("{}:{}", TagUpper, Aliased);
138				let ShouldPrint = {
139					if let Ok(mut State) = $crate::DevLog::DEDUP.lock() {
140						if State.LastKey == Key {
141							State.Count += 1;
142							false
143						} else {
144							let PrevCount = State.Count;
145							let HadPrev = !State.LastKey.is_empty();
146							State.LastKey = Key;
147							State.Count = 1;
148							if HadPrev && PrevCount > 1 {
149								eprintln!("  (x{})", PrevCount);
150							}
151							true
152						}
153					} else {
154						true
155					}
156				};
157				if ShouldPrint {
158					eprintln!("[DEV:{}] {}", TagUpper, Aliased);
159				}
160			} else {
161				eprintln!("[DEV:{}] {}", TagUpper, RawMessage);
162			}
163		}
164	};
165}
166
167// ============================================================================
168// OTLP Span Emission — sends spans directly to Jaeger/OTEL collector
169// ============================================================================
170
171use std::sync::atomic::{AtomicBool, Ordering};
172use std::time::{SystemTime, UNIX_EPOCH};
173
174static OTLP_AVAILABLE:AtomicBool = AtomicBool::new(true);
175static OTLP_TRACE_ID:OnceLock<String> = OnceLock::new();
176
177fn GetTraceId() -> &'static str {
178	OTLP_TRACE_ID.get_or_init(|| {
179		use std::collections::hash_map::DefaultHasher;
180		use std::hash::{Hash, Hasher};
181		let mut H = DefaultHasher::new();
182		std::process::id().hash(&mut H);
183		SystemTime::now()
184			.duration_since(UNIX_EPOCH)
185			.unwrap_or_default()
186			.as_nanos()
187			.hash(&mut H);
188		format!("{:032x}", H.finish() as u128)
189	})
190}
191
192pub fn NowNano() -> u64 {
193	SystemTime::now()
194		.duration_since(UNIX_EPOCH)
195		.unwrap_or_default()
196		.as_nanos() as u64
197}
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-grove"}}}},"#,
231			r#"{{"key":"service.version","value":{{"stringValue":"0.0.1"}}}}"#,
232			r#"]}},"scopeSpans":[{{"scope":{{"name":"grove.host","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::io::{Read as IoRead, Write as IoWrite};
249		use std::net::TcpStream;
250		use std::time::Duration;
251
252		let Ok(mut Stream) =
253			TcpStream::connect_timeout(&"127.0.0.1:4318".parse().unwrap(), Duration::from_millis(200))
254		else {
255			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
256			return;
257		};
258		let _ = Stream.set_write_timeout(Some(Duration::from_millis(200)));
259		let _ = Stream.set_read_timeout(Some(Duration::from_millis(200)));
260
261		let HttpReq = format!(
262			"POST /v1/traces HTTP/1.1\r\nHost: 127.0.0.1:4318\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
263			Payload.len()
264		);
265		if Stream.write_all(HttpReq.as_bytes()).is_err() { return; }
266		if Stream.write_all(Payload.as_bytes()).is_err() { return; }
267		let mut Buf = [0u8; 32];
268		let _ = Stream.read(&mut Buf);
269		if !(Buf.starts_with(b"HTTP/1.1 2") || Buf.starts_with(b"HTTP/1.0 2")) {
270			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
271		}
272	});
273}
274
275fn rand_u64() -> u64 {
276	use std::collections::hash_map::DefaultHasher;
277	use std::hash::{Hash, Hasher};
278	let mut H = DefaultHasher::new();
279	std::thread::current().id().hash(&mut H);
280	NowNano().hash(&mut H);
281	H.finish()
282}
283
284/// Convenience macro: emit an OTLP span for a Grove handler.
285/// Usage: `otel_span!("grove:activate", StartNano, &[("extension", &Id)]);`
286#[macro_export]
287macro_rules! otel_span {
288	($Name:expr, $Start:expr, $Attrs:expr) => {
289		$crate::DevLog::EmitOTLPSpan($Name, $Start, $crate::DevLog::NowNano(), $Attrs)
290	};
291	($Name:expr, $Start:expr) => {
292		$crate::DevLog::EmitOTLPSpan($Name, $Start, $crate::DevLog::NowNano(), &[])
293	};
294}