Skip to main content

Grove/
DevLog.rs

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