Skip to main content

Mountain/IPC/
DevLog.rs

1//! # DevLog - Tag-filtered development logging
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=vfs,ipc ./Mountain          # only VFS + IPC
9//! LAND_DEV_LOG=all ./Mountain              # everything
10//! LAND_DEV_LOG=short ./Mountain            # everything, compressed + deduped
11//! LAND_DEV_LOG=terminal,exthost ./Mountain # terminal + extension host
12//! ./Mountain                               # nothing (only normal log!() output)
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 (38 granular tags across all Elements)
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//! | `folder`      | Folder picker, workspace navigation                  |
32//! | `exthost`     | Extension host: create, start, kill, exit info       |
33//! | `extensions`  | Extension scanning, activation, management           |
34//! | `terminal`    | Terminal/PTY: create, sendText, profiles, shell      |
35//! | `search`      | Search: findFiles, findInFiles                       |
36//! | `themes`      | Theme: list, get active, set                         |
37//! | `window`      | Window: focus, maximize, minimize, fullscreen        |
38//! | `nativehost`  | OS integration: process, devtools, shell             |
39//! | `clipboard`   | Clipboard: read/write text, buffer, image            |
40//! | `commands`    | Command registry: execute, getAll                    |
41//! | `model`       | Text model: open, close, get, updateContent          |
42//! | `output`      | Output channels: create, append, show                |
43//! | `notification`| Notifications: show, progress                        |
44//! | `progress`    | Progress: begin, end, report                         |
45//! | `quickinput`  | Quick input: showQuickPick, showInputBox             |
46//! | `workingcopy` | Working copy: dirty state                            |
47//! | `workspaces`  | Workspace: folders, recent, enter                    |
48//! | `keybinding`  | Keybindings: add, remove, lookup                     |
49//! | `label`       | Label service: getBase, getUri                       |
50//! | `history`     | Navigation history: push, goBack, goForward          |
51//! | `decorations` | Decorations: get, set, clear                         |
52//! | `textfile`    | Text file operations: read, write, save              |
53//! | `update`      | Update service: check, download, apply               |
54//! | `encryption`  | Encryption: encrypt, decrypt                         |
55//! | `menubar`     | Menubar updates                                      |
56//! | `url`         | URL handler: registerExternalUriOpener               |
57//! | `grpc`        | gRPC/Vine: server, client, connections               |
58//! | `cocoon`      | Cocoon sidecar: spawn, health, handshake             |
59//! | `bootstrap`   | Effect-TS bootstrap stages                           |
60//! | `preload`     | Preload: globals, polyfills, ipcRenderer             |
61
62use std::sync::{Mutex, OnceLock};
63
64static ENABLED_TAGS:OnceLock<Vec<String>> = OnceLock::new();
65static SHORT_MODE:OnceLock<bool> = OnceLock::new();
66
67// ── Path alias ──────────────────────────────────────────────────────────
68// The app-data directory name is absurdly long. In short mode, alias it.
69static APP_DATA_PREFIX:OnceLock<Option<String>> = OnceLock::new();
70
71fn DetectAppDataPrefix() -> Option<String> {
72	// Match the bundle identifier pattern used by Mountain
73	if let Ok(Home) = std::env::var("HOME") {
74		let Base = format!("{}/Library/Application Support", Home);
75		if let Ok(Entries) = std::fs::read_dir(&Base) {
76			for Entry in Entries.flatten() {
77				let Name = Entry.file_name();
78				let Name = Name.to_string_lossy();
79				if Name.starts_with("land.editor.") && Name.contains("mountain") {
80					return Some(format!("{}/{}", Base, Name));
81				}
82			}
83		}
84	}
85	None
86}
87
88/// Get the app-data path prefix for aliasing (cached).
89pub fn AppDataPrefix() -> &'static Option<String> { APP_DATA_PREFIX.get_or_init(DetectAppDataPrefix) }
90
91/// Replace the long app-data path with `$APP` in a string.
92pub fn AliasPath(Input:&str) -> String {
93	if let Some(Prefix) = AppDataPrefix() {
94		Input.replace(Prefix.as_str(), "$APP")
95	} else {
96		Input.to_string()
97	}
98}
99
100// ── Dedup buffer ────────────────────────────────────────────────────────
101
102pub struct DedupState {
103	pub LastKey:String,
104	pub Count:u64,
105}
106
107pub static DEDUP:Mutex<DedupState> = Mutex::new(DedupState { LastKey:String::new(), Count:0 });
108
109/// Flush the dedup buffer - prints the pending count if > 1.
110pub fn FlushDedup() {
111	if let Ok(mut State) = DEDUP.lock() {
112		if State.Count > 1 {
113			eprintln!("  (x{})", State.Count);
114		}
115		State.LastKey.clear();
116		State.Count = 0;
117	}
118}
119
120// ── Tag resolution ──────────────────────────────────────────────────────
121
122fn EnabledTags() -> &'static Vec<String> {
123	ENABLED_TAGS.get_or_init(|| {
124		match std::env::var("LAND_DEV_LOG") {
125			Ok(Val) => Val.split(',').map(|S| S.trim().to_lowercase()).collect(),
126			Err(_) => vec![],
127		}
128	})
129}
130
131/// Whether `LAND_DEV_LOG=short` is active.
132pub fn IsShort() -> bool { *SHORT_MODE.get_or_init(|| EnabledTags().iter().any(|T| T == "short")) }
133
134/// Check if a tag is enabled.
135pub fn IsEnabled(Tag:&str) -> bool {
136	let Tags = EnabledTags();
137	if Tags.is_empty() {
138		return false;
139	}
140	let Lower = Tag.to_lowercase();
141	Tags.iter().any(|T| T == "all" || T == "short" || T == Lower.as_str())
142}
143
144/// Log a tagged dev message. Only prints if the tag is enabled via
145/// LAND_DEV_LOG.
146///
147/// In `short` mode: aliases long paths, deduplicates consecutive identical
148/// lines.
149#[macro_export]
150macro_rules! dev_log {
151	($Tag:expr, $($Arg:tt)*) => {
152		if $crate::IPC::DevLog::IsEnabled($Tag) {
153			let RawMessage = format!($($Arg)*);
154			let TagUpper = $Tag.to_uppercase();
155			if $crate::IPC::DevLog::IsShort() {
156				let Aliased = $crate::IPC::DevLog::AliasPath(&RawMessage);
157				let Key = format!("{}:{}", TagUpper, Aliased);
158				let ShouldPrint = {
159					if let Ok(mut State) = $crate::IPC::DevLog::DEDUP.lock() {
160						if State.LastKey == Key {
161							State.Count += 1;
162							false
163						} else {
164							let PrevCount = State.Count;
165							let HadPrev = !State.LastKey.is_empty();
166							State.LastKey = Key;
167							State.Count = 1;
168							if HadPrev && PrevCount > 1 {
169								eprintln!("  (x{})", PrevCount);
170							}
171							true
172						}
173					} else {
174						true
175					}
176				};
177				if ShouldPrint {
178					eprintln!("[DEV:{}] {}", TagUpper, Aliased);
179				}
180			} else {
181				eprintln!("[DEV:{}] {}", TagUpper, RawMessage);
182			}
183		}
184	};
185}
186
187// ============================================================================
188// OTLP Span Emission — sends spans directly to Jaeger/OTEL collector
189// ============================================================================
190
191use std::{
192	sync::atomic::{AtomicBool, Ordering},
193	time::{SystemTime, UNIX_EPOCH},
194};
195
196static OTLP_AVAILABLE:AtomicBool = AtomicBool::new(true);
197static OTLP_TRACE_ID:OnceLock<String> = OnceLock::new();
198
199fn GetTraceId() -> &'static str {
200	OTLP_TRACE_ID.get_or_init(|| {
201		use std::{
202			collections::hash_map::DefaultHasher,
203			hash::{Hash, Hasher},
204		};
205		let mut H = DefaultHasher::new();
206		std::process::id().hash(&mut H);
207		SystemTime::now()
208			.duration_since(UNIX_EPOCH)
209			.unwrap_or_default()
210			.as_nanos()
211			.hash(&mut H);
212		format!("{:032x}", H.finish() as u128)
213	})
214}
215
216pub fn NowNano() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64 }
217
218/// Emit an OTLP span to the local collector (Jaeger at 127.0.0.1:4318).
219/// Fire-and-forget on a background thread. Stops trying after first failure.
220pub fn EmitOTLPSpan(Name:&str, StartNano:u64, EndNano:u64, Attributes:&[(&str, &str)]) {
221	if !cfg!(debug_assertions) {
222		return;
223	}
224	if !OTLP_AVAILABLE.load(Ordering::Relaxed) {
225		return;
226	}
227
228	let SpanId = format!("{:016x}", rand_u64());
229	let TraceId = GetTraceId().to_string();
230	let SpanName = Name.to_string();
231
232	let AttributesJson:Vec<String> = Attributes
233		.iter()
234		.map(|(K, V)| {
235			format!(
236				r#"{{"key":"{}","value":{{"stringValue":"{}"}}}}"#,
237				K,
238				V.replace('\\', "\\\\").replace('"', "\\\"")
239			)
240		})
241		.collect();
242
243	let IsError = SpanName.contains("error");
244
245	let StatusCode = if IsError { 2 } else { 1 };
246	let Payload = format!(
247		concat!(
248			r#"{{"resourceSpans":[{{"resource":{{"attributes":["#,
249			r#"{{"key":"service.name","value":{{"stringValue":"land-editor-mountain"}}}},"#,
250			r#"{{"key":"service.version","value":{{"stringValue":"0.0.1"}}}}"#,
251			r#"]}},"scopeSpans":[{{"scope":{{"name":"mountain.ipc","version":"1.0.0"}},"#,
252			r#""spans":[{{"traceId":"{}","spanId":"{}","name":"{}","kind":1,"#,
253			r#""startTimeUnixNano":"{}","endTimeUnixNano":"{}","#,
254			r#""attributes":[{}],"status":{{"code":{}}}}}]}}]}}]}}"#,
255		),
256		TraceId,
257		SpanId,
258		SpanName,
259		StartNano,
260		EndNano,
261		AttributesJson.join(","),
262		StatusCode,
263	);
264
265	// Fire-and-forget on a background thread
266	std::thread::spawn(move || {
267		use std::{
268			io::{Read as IoRead, Write as IoWrite},
269			net::TcpStream,
270			time::Duration,
271		};
272
273		let Ok(mut Stream) = TcpStream::connect_timeout(&"127.0.0.1:4318".parse().unwrap(), Duration::from_millis(200))
274		else {
275			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
276			return;
277		};
278		let _ = Stream.set_write_timeout(Some(Duration::from_millis(200)));
279		let _ = Stream.set_read_timeout(Some(Duration::from_millis(200)));
280
281		let HttpReq = format!(
282			"POST /v1/traces HTTP/1.1\r\nHost: 127.0.0.1:4318\r\nContent-Type: application/json\r\nContent-Length: \
283			 {}\r\nConnection: close\r\n\r\n",
284			Payload.len()
285		);
286		if Stream.write_all(HttpReq.as_bytes()).is_err() {
287			return;
288		}
289		if Stream.write_all(Payload.as_bytes()).is_err() {
290			return;
291		}
292		let mut Buf = [0u8; 32];
293		let _ = Stream.read(&mut Buf);
294		if !(Buf.starts_with(b"HTTP/1.1 2") || Buf.starts_with(b"HTTP/1.0 2")) {
295			OTLP_AVAILABLE.store(false, Ordering::Relaxed);
296		}
297	});
298}
299
300fn rand_u64() -> u64 {
301	use std::{
302		collections::hash_map::DefaultHasher,
303		hash::{Hash, Hasher},
304	};
305	let mut H = DefaultHasher::new();
306	std::thread::current().id().hash(&mut H);
307	NowNano().hash(&mut H);
308	H.finish()
309}
310
311/// Convenience macro: emit an OTLP span for an IPC handler.
312/// Usage: `otel_span!("file:readFile", StartNano, &[("path", &SomePath)]);`
313#[macro_export]
314macro_rules! otel_span {
315	($Name:expr, $Start:expr, $Attrs:expr) => {
316		$crate::IPC::DevLog::EmitOTLPSpan($Name, $Start, $crate::IPC::DevLog::NowNano(), $Attrs)
317	};
318	($Name:expr, $Start:expr) => {
319		$crate::IPC::DevLog::EmitOTLPSpan($Name, $Start, $crate::IPC::DevLog::NowNano(), &[])
320	};
321}