Mountain/IPC/DevLog.rs
1//! # DevLog - Tag-filtered development logging
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=vfs,ipc ./Mountain # only VFS + IPC
9//! Trace=all ./Mountain # everything
10//! Trace=short ./Mountain # everything, compressed + deduped
11//! Trace=terminal,exthost ./Mountain # terminal + extension host
12//! ./Mountain # nothing (only normal log!() output)
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//! ## File sink
23//!
24//! When `Record=1` (or, in debug builds, `Trace` is set),
25//! every emitted line is mirrored to:
26//!
27//! ```
28//! ~/Library/Application Support/<bundle>/logs/<YYYYMMDDTHHMMSS>/Mountain.dev.log
29//! ```
30//!
31//! The timestamp directory follows Tauri's `tauri-plugin-log` format, so
32//! the dev log sits next to the plugin's own log file for the same boot
33//! (one directory per process start). Use `Record=0` to force-
34//! disable even in debug. File writes are flushed per line so `tail -f`
35//! shows live output.
36//!
37//! ## Tags (38 granular tags across all Elements)
38//!
39//! | Tag | Scope |
40//! |---------------|-----------------------------------------------------|
41//! | `vfs` | File stat, read, write, readdir, mkdir, delete, copy|
42//! | `ipc` | IPC routing: invoke dispatch, channel calls |
43//! | `config` | Configuration get/set, env paths, workbench config |
44//! | `lifecycle` | Startup, shutdown, phases, window events |
45//! | `storage` | Storage get/set/delete, items, optimize |
46//! | `folder` | Folder picker, workspace navigation |
47//! | `exthost` | Extension host: create, start, kill, exit info |
48//! | `extensions` | Extension scanning, activation, management |
49//! | `terminal` | Terminal/PTY: create, sendText, profiles, shell |
50//! | `search` | Search: findFiles, findInFiles |
51//! | `themes` | Theme: list, get active, set |
52//! | `window` | Window: focus, maximize, minimize, fullscreen |
53//! | `nativehost` | OS integration: process, devtools, shell |
54//! | `clipboard` | Clipboard: read/write text, buffer, image |
55//! | `commands` | Command registry: execute, getAll |
56//! | `model` | Text model: open, close, get, updateContent |
57//! | `output` | Output channels: create, append, show |
58//! | `notification`| Notifications: show, progress |
59//! | `progress` | Progress: begin, end, report |
60//! | `quickinput` | Quick input: showQuickPick, showInputBox |
61//! | `workingcopy` | Working copy: dirty state |
62//! | `workspaces` | Workspace: folders, recent, enter |
63//! | `keybinding` | Keybindings: add, remove, lookup |
64//! | `label` | Label service: getBase, getUri |
65//! | `history` | Navigation history: push, goBack, goForward |
66//! | `decorations` | Decorations: get, set, clear |
67//! | `textfile` | Text file operations: read, write, save |
68//! | `update` | Update service: check, download, apply |
69//! | `encryption` | Encryption: encrypt, decrypt |
70//! | `menubar` | Menubar updates |
71//! | `url` | URL handler: registerExternalUriOpener |
72//! | `grpc` | gRPC/Vine: server, client, connections |
73//! | `cocoon` | Cocoon sidecar: spawn, health, handshake |
74//! | `bootstrap` | Effect-TS bootstrap stages |
75//! | `preload` | Preload: globals, polyfills, ipcRenderer |
76//!
77//! ### Batch 1 diagnostic tags (Row 1-3 fix surfaces)
78//!
79//! Narrow tags added to isolate the architectural gaps surfaced by the
80//! analysis table. Enable selectively with e.g.
81//! `Trace=notif-drop,git,tree-view` to filter to just the
82//! subsystem under investigation.
83//!
84//! | Tag | Scope |
85//! |---------------------|-----------------------------------------------------------------------|
86//! | `notif-drop` | Notifications that hit the `_ => {}` default arm (dropped silently) |
87//! | `provider-register` | Accepted `register_*_provider` notifications + handle + extension id |
88//! | `channel-stub` | Wind/Output `TauriMainProcessService.call` stub-hit vs route vs miss |
89//! | `git` | `localGit` channel, `GitExec` RPC, SCM provider group updates |
90//! | `tree-view` | `tree.register`, `GetTreeChildren`, `sky://tree-view/create` emit |
91//!
92//! ### Batch 2 diagnostic tags
93//!
94//! | Tag | Scope |
95//! |------------------|------------------------------------------------------------------------|
96//! | `sky-emit` | Mountain → Wind/Sky `sky://` emits: channel, payload bytes, ok/fail |
97//! | `config-prime` | Configuration cache: manifest pre-populate, subtree synthesise |
98//! | `ext-activate` | Per-extension activate: start, outcome (ok/fail/skip), duration |
99//! | `breaker` | Cocoon `MountainClientService` circuit-breaker state transitions |
100//! | `cel-dispatch` | SkyBridge `cel:*` CustomEvent dispatch + consumer-present flag |
101//!
102//! ### Batch 3 diagnostic tags
103//!
104//! Added 2026-04-23 late after Batches 3-6 wired notification handlers,
105//! git channel, tree-view dataProvider forwarding, and medium stub
106//! backfill. These tags catch the subsystems the new traffic passes
107//! through so regressions surface as silent tag counts going to zero
108//! (or, for `tauri-invoke`, per-invoke latency spikes).
109//!
110//! | Tag | Scope |
111//! |--------------------|-------------------------------------------------------------------------|
112//! | `ext-scan` | Extension scanner decisions: is-builtin vs user, skip reasons, counts |
113//! | `scheme-assets` | `vscode-file://` / `vscode-resource://` request routing, MIME, bytes |
114//! | `preload-shim` | Wind `Preload.ts` globals wiring, VS Code `ipcRenderer` polyfill install|
115//! | `tauri-invoke` | Per-invoke method + duration (augments `ipc`'s paired invoke/done lines)|
116//! | `bootstrap-stage` | Cocoon `Effect/Bootstrap.ts` stage timings (start/ok/fail per phase) |
117//!
118//! ### Batch 4 diagnostic tags
119//!
120//! Added 2026-04-24 alongside the `workspace.fs` tier-split refactor.
121//! Cocoon's `WorkspaceNamespace/FileSystemRoute.ts` now chooses between
122//! Tier A (`node:fs/promises` in-process) and Tier C (Mountain `FileSystem.*`
123//! gRPC) per URI scheme + custom-provider claim. The tag surfaces every
124//! decision so empirical workload profiling confirms the split is paying
125//! off - `grep 'route=native'` / `grep 'route=mountain'` buckets per run.
126//! Emitted from Cocoon stdout, picked up by Mountain's `[DEV:COCOON]`
127//! stdout tail with the standard `[DEV:FS-ROUTE]` prefix.
128//!
129//! | Tag | Scope |
130//! |--------------------|-------------------------------------------------------------------------|
131//! | `fs-route` | `workspace.fs.*` + `openTextDocument` native-vs-mountain routing |
132//! | `cmd-route` | `commands.executeCommand` local-vs-mountain routing |
133//! | `dual-track` | Mountain-first / Node-fallback progressive Rust migration dispatches |
134//!
135//! ### `dual-track` tag usage pattern
136//!
137//! Every Cocoon shim that wraps `TryMountainThenNode` logs one of four
138//! decisions per dispatch:
139//!
140//! - `route=mountain` Mountain handled it (Rust code path served the
141//! call)
142//! - `route=node-fallback` Build-time manifest says no Mountain handler (or
143//! runtime confirmed "Unknown method: X"); Cocoon's Node / stock-VS-Code
144//! implementation served it
145//! - `route=unavailable` Tier-4: no tier covers the method. Extension
146//! receives typed `NotImplementedError`; build surfaces this as a
147//! feature-gap
148//! - `route=error` Tier failed unexpectedly; error propagated
149//!
150//! Grep the stream to see which `vscode.*` methods Mountain doesn't yet
151//! cover - that set IS the Rust migration TODO list. A method moves from
152//! `node-fallback` to `mountain` automatically the moment Mountain lands
153//! its Rust handler AND the manifest is regenerated. `route=unavailable`
154//! rows are the apologise-to-user list.
155//!
156//! A single `[DEV:DUAL-TRACK] manifest mountain=N stockLift=M bespoke=K`
157//! line prints at Cocoon boot with the build-time tier coverage
158//! generated by `Maintain/Script/GenerateRouteManifest.sh`.
159
160use std::{
161 fs::{File, OpenOptions, create_dir_all},
162 io::{BufWriter, Write as IoWrite},
163 path::PathBuf,
164 sync::{
165 Mutex,
166 OnceLock,
167 atomic::{AtomicBool, Ordering},
168 },
169 time::{SystemTime, UNIX_EPOCH},
170};
171
172static ENABLED_TAGS:OnceLock<Vec<String>> = OnceLock::new();
173static SHORT_MODE:OnceLock<bool> = OnceLock::new();
174
175// ── File sink ────────────────────────────────────────────────────────────
176//
177// Mirrors every `dev_log!` line to a timestamped file under the app's
178// `logs/<YYYYMMDDTHHMMSS>/` directory so long sessions can be inspected
179// post-mortem without scrolling terminal history. Enable with:
180//
181// Record=1 # explicit opt-in
182// Record=0 # explicit opt-out (wins over defaults)
183//
184// When unset, file logging is auto-enabled in debug builds iff
185// `Trace` itself is set to at least one tag. Release builds stay
186// silent unless the opt-in flag is present, to avoid surprise writes in
187// shipped binaries.
188//
189// Directory layout matches Tauri's tauri-plugin-log output (same parent
190// `logs/` and same `YYYYMMDDTHHMMSS` subdir format) so the two logs sit
191// side by side when the user greps one timestamp.
192
193static LOG_FILE:OnceLock<Mutex<Option<BufWriter<File>>>> = OnceLock::new();
194
195/// Decide whether the file sink should be active. Returns the final flag
196/// once per process; subsequent calls are cached.
197fn FileSinkEnabled() -> bool {
198 static ENABLED:OnceLock<bool> = OnceLock::new();
199 *ENABLED.get_or_init(|| {
200 match std::env::var("Record") {
201 Ok(Value) => matches!(Value.as_str(), "1" | "true" | "yes" | "on"),
202 Err(_) => {
203 // Auto-enable when Trace is set in a debug build.
204 cfg!(debug_assertions) && std::env::var("Trace").is_ok()
205 },
206 }
207 })
208}
209
210/// Resolve the log directory for this session:
211/// ~/Library/Application Support/<bundle>/logs/<YYYYMMDDTHHMMSS>/
212///
213/// The timestamp is picked once per process at first call. If the app-data
214/// prefix can't be detected (Tauri hasn't spawned yet, say), fall back to
215/// the system temp directory with the same naming - dev_log still works
216/// and the file ends up somewhere predictable.
217fn ResolveLogDirectory() -> PathBuf {
218 let Stamp = FormatTimestamp();
219 let Base = match AppDataPrefix() {
220 Some(Prefix) => PathBuf::from(Prefix).join("logs"),
221 None => std::env::temp_dir().join("land-editor-logs"),
222 };
223 Base.join(Stamp)
224}
225
226/// Session timestamp in local time, cached once per process. MUST match
227/// whatever `WindServiceHandlers.rs::"nativeHost:getEnvironmentPaths"`
228/// builds, because VS Code's file service writes `window1/output/*.log`
229/// into the directory that handler returns - if DevLog and VS Code use
230/// different timezones, `Mountain.dev.log` and the `window1/` subtree
231/// land in two sibling directories 2-3 hours apart, which makes every
232/// post-mortem investigation start with "which folder has the real
233/// log?". Picking `chrono::Local::now()` matches the VS Code convention
234/// (Tauri's tauri-plugin-log also writes local-time `YYYYMMDDTHHMMSS`).
235///
236/// The format string is deliberately identical to the handler's
237/// `"%Y%m%dT%H%M%S"`, and both sides pull from the same OnceLock via
238/// `SessionTimestamp()` so re-entrant calls from anywhere in the
239/// codebase produce the same string.
240pub fn SessionTimestamp() -> String {
241 static STAMP:OnceLock<String> = OnceLock::new();
242 STAMP
243 .get_or_init(|| chrono::Local::now().format("%Y%m%dT%H%M%S").to_string())
244 .clone()
245}
246
247fn FormatTimestamp() -> String { SessionTimestamp() }
248
249// `DaysToYMD` + `IsLeap` were previously used to build a UTC timestamp
250// string without pulling chrono into DevLog. Replaced by
251// `chrono::Local::now()` in `SessionTimestamp()` so this file agrees
252// with `WindServiceHandlers.rs::"nativeHost:getEnvironmentPaths"` on
253// the session-log directory. chrono is already a Mountain dependency,
254// so the vendored date math is dead weight now.
255
256/// Initialise the file sink on first call. Silently falls through to a
257/// disabled sink if the directory or file can't be created - the caller
258/// must never panic because of a log-file failure.
259fn InitFileSink() -> &'static Mutex<Option<BufWriter<File>>> {
260 LOG_FILE.get_or_init(|| {
261 if !FileSinkEnabled() {
262 return Mutex::new(None);
263 }
264 let Dir = ResolveLogDirectory();
265 if create_dir_all(&Dir).is_err() {
266 eprintln!("[DEV:LOG] Failed to create log directory {}", Dir.display());
267 return Mutex::new(None);
268 }
269 let Path = Dir.join("Mountain.dev.log");
270 match OpenOptions::new().create(true).append(true).open(&Path) {
271 Ok(File) => {
272 let mut Writer = BufWriter::with_capacity(64 * 1024, File);
273 // Header pins the boot-time context so every session's file
274 // is self-describing even without the surrounding terminal.
275 let Header = format!(
276 "# Land dev log - started {}, pid {}, tags={:?}, short={}\n",
277 FormatTimestamp(),
278 std::process::id(),
279 EnabledTags(),
280 IsShort(),
281 );
282 let _ = Writer.write_all(Header.as_bytes());
283 let _ = Writer.flush();
284 eprintln!("[DEV:LOG] File sink → {}", Path.display());
285 Mutex::new(Some(Writer))
286 },
287 Err(Error) => {
288 eprintln!("[DEV:LOG] Failed to open {}: {}", Path.display(), Error);
289 Mutex::new(None)
290 },
291 }
292 })
293}
294
295/// Force the file sink to initialize before any `dev_log!` has run.
296///
297/// `WriteToFile` is otherwise lazy - the log file is only opened the first
298/// time a tagged `dev_log!` call fires. When Mountain panics (or the
299/// webview traps the user with an early error) before the first enabled
300/// tag emits, the session log directory ends up with an empty shell and
301/// the post-mortem evidence is lost.
302///
303/// Call this once at the top of `Binary::Main::Fn()` - as early as the
304/// binary can reach - so the header line + `Record=1` opt-in
305/// are honoured even when nothing else ever logs. Harmless to call
306/// multiple times; the `OnceLock` inside `InitFileSink` gates it.
307pub fn InitEager() { let _ = InitFileSink(); }
308
309/// Append a single formatted line to the session's log file if the file
310/// sink is active. Swallows every error - dev_log must never crash.
311pub fn WriteToFile(Line:&str) {
312 let Sink = InitFileSink();
313 if let Ok(mut Guard) = Sink.lock() {
314 if let Some(Writer) = Guard.as_mut() {
315 let _ = Writer.write_all(Line.as_bytes());
316 if !Line.ends_with('\n') {
317 let _ = Writer.write_all(b"\n");
318 }
319 // Flush on every line - ordering/tail-f matters more than throughput
320 // for dev logs, and the BufWriter coalesces partial writes anyway.
321 let _ = Writer.flush();
322 }
323 }
324}
325
326// ── Path alias ──────────────────────────────────────────────────────────
327// The app-data directory name is absurdly long. In short mode, alias it.
328static APP_DATA_PREFIX:OnceLock<Option<String>> = OnceLock::new();
329
330/// Produce an identity signature for THIS running binary derived from
331/// `CARGO_PKG_NAME` (which Maintain sets to the long PascalCase product
332/// name before `cargo build`). Each profile produces a distinct signature
333/// - `_Debug_Mountain` → `.debug.mountain`, `_Compile_Mountain` →
334/// `.compile.mountain`, `_Bundle_Clean_Debug_ElectronProfile_Mountain`
335/// → `.debug.electron.profile.mountain` - so a candidate directory in
336/// `~/Library/Application Support/` can be disambiguated against every
337/// other `land.editor.*.mountain` leftover from prior runs.
338///
339/// Only the last three underscore-delimited segments are used: the
340/// leading `DevelopmentNodeEnvironment_MicrosoftVSCodeDependency_
341/// 22NodeVersion_Bundle_Clean` prefix is identical across profiles and
342/// doesn't help disambiguate, while the tail (`Debug_Mountain` vs
343/// `Compile_Mountain` vs `Debug_ElectronProfile_Mountain`) is where the
344/// per-profile identity lives.
345fn BinarySignature() -> String {
346 let PackageName = env!("CARGO_PKG_NAME");
347 let Segments:Vec<&str> = PackageName.split('_').collect();
348 let Take = Segments.len().min(4);
349 let Start = Segments.len().saturating_sub(Take);
350 // Each underscore-delimited segment is PascalCase (e.g. `ElectronProfile`,
351 // `MountainProfile`, `RestCompiler`). The Tauri identifier Maintain
352 // generates splits every PascalCase word on its capital boundary, so
353 // `ElectronProfile` → `electron.profile`, not `electronprofile`. Without
354 // the per-segment split here the signature becomes `electronprofile`
355 // and `ends_with` never matches the real Tauri app-data dir
356 // `…clean.debug.electron.profile.mountain`, so DevLog silently falls
357 // back to whichever other `*.mountain` directory `read_dir` yielded
358 // first - the bug that sent the electron-profile binary's logs into
359 // the compile-profile directory.
360 let Dotted:String = Segments[Start..]
361 .iter()
362 .flat_map(|Segment| SplitPascalCaseIntoWords(Segment))
363 .collect::<Vec<String>>()
364 .join(".")
365 .to_ascii_lowercase();
366 Dotted
367}
368
369/// Split a PascalCase / UPPERCASE string into lowercase component words,
370/// matching the tokenisation Maintain's `Build/Process.rs` applies when it
371/// stamps the Tauri `identifier`. Example: `ElectronProfile` →
372/// `["electron", "profile"]`; `22NodeVersion` → `["22", "node", "version"]`.
373/// Empty segments are filtered out.
374fn SplitPascalCaseIntoWords(Segment:&str) -> Vec<String> {
375 let mut Words:Vec<String> = Vec::new();
376 let mut Current = String::new();
377 let mut PrevWasUpper = false;
378 let mut PrevWasDigit = false;
379 for Ch in Segment.chars() {
380 let IsUpper = Ch.is_ascii_uppercase();
381 let IsDigit = Ch.is_ascii_digit();
382 let NeedBreak =
383 !Current.is_empty() && ((IsUpper && !PrevWasUpper) || (IsDigit != PrevWasDigit && !Current.is_empty()));
384 if NeedBreak {
385 Words.push(std::mem::take(&mut Current));
386 }
387 Current.push(Ch);
388 PrevWasUpper = IsUpper;
389 PrevWasDigit = IsDigit;
390 }
391 if !Current.is_empty() {
392 Words.push(Current);
393 }
394 Words.into_iter().filter(|Word| !Word.is_empty()).collect()
395}
396
397fn DetectAppDataPrefix() -> Option<String> {
398 let Home = std::env::var("HOME").ok()?;
399 let Base = format!("{}/Library/Application Support", Home);
400 let Signature = BinarySignature();
401
402 // Prefer a directory whose name ends with this binary's unique tail
403 // signature: that's the app-data directory Tauri created for THIS
404 // profile. Without this check, a user who has ever launched another
405 // profile (debug-electron, release-electron, …) will see DevLog
406 // writing into that stale directory while userdata still goes into
407 // the current one, producing an "empty logs folder" mystery.
408 let mut FirstMatchingMountain:Option<String> = None;
409 if let Ok(Entries) = std::fs::read_dir(&Base) {
410 for Entry in Entries.flatten() {
411 let Name = Entry.file_name();
412 let Name = Name.to_string_lossy().into_owned();
413 if !Name.starts_with("land.editor.") || !Name.contains("mountain") {
414 continue;
415 }
416 // Strict match: binary signature tail is a suffix of the dir name.
417 if Name.ends_with(&Signature) {
418 return Some(format!("{}/{}", Base, Name));
419 }
420 // Lossy match: some segment of the binary signature is contained
421 // in the dir name. Used only if no strict match exists.
422 if FirstMatchingMountain.is_none() {
423 FirstMatchingMountain = Some(format!("{}/{}", Base, Name));
424 }
425 }
426 }
427 FirstMatchingMountain
428}
429
430/// Get the app-data path prefix for aliasing (cached).
431pub fn AppDataPrefix() -> &'static Option<String> { APP_DATA_PREFIX.get_or_init(DetectAppDataPrefix) }
432
433/// Replace the long app-data path with `$APP` in a string.
434pub fn AliasPath(Input:&str) -> String {
435 if let Some(Prefix) = AppDataPrefix() {
436 Input.replace(Prefix.as_str(), "$APP")
437 } else {
438 Input.to_string()
439 }
440}
441
442// ── Dedup buffer ────────────────────────────────────────────────────────
443
444pub struct DedupState {
445 pub LastKey:String,
446 pub Count:u64,
447}
448
449pub static DEDUP:Mutex<DedupState> = Mutex::new(DedupState { LastKey:String::new(), Count:0 });
450
451/// Flush the dedup buffer - prints the pending count if > 1.
452pub fn FlushDedup() {
453 if let Ok(mut State) = DEDUP.lock() {
454 if State.Count > 1 {
455 let Tail = format!(" (x{})", State.Count);
456 eprintln!("{}", Tail);
457 WriteToFile(&Tail);
458 }
459 State.LastKey.clear();
460 State.Count = 0;
461 }
462}
463
464// ── Benign-probe classification (BATCH-17) ───────────────────────────────
465//
466// Extensions and the workbench probe dozens of optional files on every boot
467// (VS Code + Copilot + language-features). `stat ENOENT` lines for these
468// paths are functionally noise: they confirm the probe exists but nothing
469// acts on a failure. Three steps keep the log useful:
470//
471// 1. Known-optional patterns downgrade to Debug level (suppressed from the
472// default dev-log stream, still written to the file sink).
473// 2. Per-unique-path dedup: log the first miss once per session via
474// [`DebugOnce`]; later hits on the same path are swallowed.
475// 3. Virtual resource 404s (`vscode://`, cached globalStorage paths) are
476// matched by the same helper so `BATCH-06`'s earlier suppression stays in
477// one place.
478
479const BENIGN_ENOENT_SUBSTRINGS:&[&str] = &[
480 // VS Code / Claude / Copilot probe paths. Bare `/.claude` and `/.vscode`
481 // entries cover extension walk-ups that stat the directory itself before
482 // looking inside; the per-file variants below remain for self-documentation
483 // but are supersets of the bare directory match.
484 "/.claude",
485 "/.vscode",
486 ".claude/agents",
487 ".claude/settings.json",
488 ".claude/settings.local.json",
489 ".copilot/agents",
490 ".github/copilot",
491 ".github/agents",
492 ".vscode/settings.json",
493 ".vscode/launch.json",
494 ".vscode/extensions.json",
495 ".vscode/tasks.json",
496 ".vscode/mcp.json",
497 ".mcp.json",
498 "agentPlugins",
499 "agent-plugins",
500 "chatEditingSessions",
501 "chatSessions",
502 // Per-extension state probes.
503 "machineid",
504 "terminalSuggestGlobalsCacheV2.json",
505 "globalStorage",
506 // Optional user-level workbench config files. On fresh profiles these do
507 // not exist; the workbench probes them on every boot and creates on first
508 // write. These are the `$APP/User/<file>` forms emitted after
509 // `resolve_userdata`.
510 "/User/tasks.json",
511 "/User/mcp.json",
512 "/User/snippets",
513 "/User/keybindings.json",
514 // Workspace-storage sidecar files the workbench stats on every project
515 // open - absent until the AI-generated-workspace feature writes one.
516 "aiGeneratedWorkspaces.json",
517 // Git extension probes `.git/config` on every workspace folder to
518 // detect whether the folder is a git worktree; ENOENT is the
519 // normal "not a git repo" signal, not an error.
520 "/.git/config",
521 // Chat language-model registry is written on first chat interaction.
522 // Absent on fresh profiles; VS Code reads-before-first-write every boot.
523 "chatLanguageModels.json",
524 // VS Code writes per-profile configuration default overrides lazily; on
525 // a fresh profile the file does not yet exist and the workbench probes
526 // on every boot to see if it needs reading.
527 "configurationDefaultsOverrides",
528 // Chat images cache directory is lazy-created on first chat attachment.
529 "vscode-chat-images",
530 // Per-window output channel log files probed lazily by the workbench
531 // before first write. Path shape: `$APP/logs/<SESSION>/window<N>/output_<TIMESTAMP>`.
532 "/output_20",
533 // Per-window named log files VS Code stats on boot to detect crash-log
534 // rollovers. The `/<logsPath>/window<N>/<name>.log` layout means the
535 // window index can change, so match on filename alone - these names
536 // are stable VS Code conventions and never collide with real files.
537 "/network.log",
538 "/renderer.log",
539 "/views.log",
540 "/notebook.rendering.log",
541 // Virtual scheme misses already covered by earlier batches.
542 "vscode://schemas-associations/",
543 // First-run state files extensions create on demand.
544 "vscodevim.vim/.registers",
545 // Per-extension globalStorage subdirectories the workbench probes
546 // before the extension creates them (clangd, midudev.better-svg,
547 // muhammad-sammy.csharp, etc.).
548 "/User/globalStorage/",
549 // Per-window output channel + chat session state - lazy-created on
550 // first emit. Path shape:
551 // `<APP>/User/workspaceStorage/<wsId>/chatEditingSessions/<id>/state.json`.
552 "/chatEditingSessions/",
553 // User-level prompt and snippet folders - stock VS Code treats
554 // both as optional and lazily creates on first write. Already have
555 // `/User/snippets`; adding the prompt counterpart here.
556 "/User/prompts",
557 // Language-detection worker's seed file; absent on fresh profiles.
558 "languageDetectionWorkerCache.json",
559 // Editor-detection: VS Code's external-editor picker (and the vim
560 // + shell extensions) stat known editor app bundles to populate
561 // "Open With…" menus. Absent on a clean machine - expected.
562 "/Applications/Eclipse IDE.app",
563 "/Applications/Eclipse.app",
564 "/Applications/IntelliJ IDEA.app",
565 "/Applications/IntelliJ IDEA CE.app",
566 "/Applications/Sublime Text.app",
567 "/Applications/Notepad++.app",
568 "/Applications/Visual Studio Code.app",
569 "/Applications/Xcode.app",
570 // Vim migration path: vscodevim + others stat the user's Neovim /
571 // Vim config to offer an import wizard. Absent = "user has no
572 // existing Vim config", which is the default not an error.
573 "/.config/nvim/init.lua",
574 "/.config/nvim/init.vim",
575 "/.vimrc",
576 "/.gvimrc",
577 // SQLite state backing files workbench lazy-creates on first
578 // write. Present on subsequent runs; absent on first-ever launch
579 // of a profile.
580 "/state.vscdb",
581 "/state.vscdb-journal",
582 // Node module resolution often stats paths that don't exist as
583 // part of the resolution ladder. Already handled for
584 // `/User/globalStorage/`; add the companion workspaceStorage
585 // subtree too.
586 "/User/workspaceStorage/",
587 // gitlens probes `~/.land/globalStorage/<extId>/` before its first
588 // write, plus per-feature subdirs `gitlens/launchpad/`. Same lazy
589 // creation pattern as the other extension state files above; absent
590 // on every fresh profile boot.
591 "/globalStorage/eamodio.gitlens",
592 "/globalStorage/GitHub.copilot",
593 "/globalStorage/GitHub.copilot-chat",
594 "/globalStorage/Anthropic.claude-code",
595 "/globalStorage/RooVeterinaryInc.roo-cline",
596 // vim's per-mode register store: lazy-created on first Yank/Paste.
597 ".registers",
598 // Sky / Output bundled `product.json` and friends are read by
599 // gitlens / copilot to detect host product metadata. Probed before
600 // they exist on first build.
601 "/Sky/Target/product.json",
602 "/Output/Target/product.json",
603];
604
605/// Return true when the given path is a known-optional probe whose absence
606/// is never an error condition. Used to downgrade `stat ENOENT` spam.
607pub fn IsBenignEnoent(Path:&str) -> bool { BENIGN_ENOENT_SUBSTRINGS.iter().any(|Needle| Path.contains(Needle)) }
608
609static DEBUG_ONCE_KEYS:OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
610
611fn DebugOnceKeys() -> &'static Mutex<std::collections::HashSet<String>> {
612 DEBUG_ONCE_KEYS.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
613}
614
615/// Emit the line exactly once per process, keyed on the supplied key. Later
616/// calls with the same key are silently dropped. The file sink still
617/// captures the very first occurrence so the probe is documented.
618pub fn DebugOnce(Tag:&str, Key:&str, Line:&str) {
619 if let Ok(mut Keys) = DebugOnceKeys().lock() {
620 if !Keys.insert(Key.to_string()) {
621 return;
622 }
623 }
624 if IsEnabled(Tag) || IsEnabled("all") {
625 let Formatted = format!("[DEV:{}] {}", Tag.to_uppercase(), Line);
626 eprintln!("{}", Formatted);
627 WriteToFile(&Formatted);
628 } else {
629 // Never echo to the console, but preserve in the file sink so
630 // post-mortems still see which probe paths fired.
631 let Formatted = format!("[DEV:{}/once] {}", Tag.to_uppercase(), Line);
632 WriteToFile(&Formatted);
633 }
634}
635
636// ── Tag resolution ──────────────────────────────────────────────────────
637
638fn EnabledTags() -> &'static Vec<String> {
639 ENABLED_TAGS.get_or_init(|| {
640 match std::env::var("Trace") {
641 Ok(Val) => Val.split(',').map(|S| S.trim().to_lowercase()).collect(),
642 Err(_) => vec![],
643 }
644 })
645}
646
647/// Whether `Trace=short` is active.
648pub fn IsShort() -> bool { *SHORT_MODE.get_or_init(|| EnabledTags().iter().any(|T| T == "short")) }
649
650/// Tags explicitly muted by `short` mode. These are the per-call
651/// firehose tags (receive / dispatch / verify / forward banners) that
652/// add no diagnostic signal beyond the aggregate IPC round-trip timing
653/// already present in the `ipc` / `grpc` summary lines. Listing them
654/// here keeps `short` usable as a "quiet but informative" default - the
655/// failure path for each of these still logs a specific error tag
656/// (`grpc` for gRPC errors, `ipc` for IPC failures, etc.).
657///
658/// Anything in this list is reachable only via `Trace=all`, an
659/// explicit tag match (e.g. `Trace=grpc-verbose`), or the
660/// tag-specific env override.
661const SHORT_MODE_MUTED_TAGS:&[&str] = &[
662 "grpc-verbose",
663 "vfs-verbose",
664 "fs-route",
665 "tauri-invoke",
666 "rpc-latency",
667 "tree-latency",
668 "nls",
669 "fs-read",
670 "preflight",
671 "wsns",
672 "storage-verbose",
673 "config-prime",
674 "cel-dispatch",
675 "output-verbose",
676 "command-register",
677 "provider-register",
678 "ext-scan-verbose",
679 "channel-stub",
680 "commands-verbose",
681 "scheme-assets",
682 "cocoon-stderr-verbose",
683 // `[DEV:VSCODE-API-GAP]` (emitted from Cocoon's WrapNamespaceWithHeuristics
684 // every time an extension reaches for a vscode.<ns>.<method> we haven't
685 // formally shimmed) is a per-method audit trail. Useful when actively
686 // auditing the API gap, noisy in default short-mode runs - mute by default;
687 // reachable via `Trace=all` or `Trace=vscode-api-gap`.
688 "vscode-api-gap",
689];
690
691/// Check if a tag is enabled.
692pub fn IsEnabled(Tag:&str) -> bool {
693 let Tags = EnabledTags();
694 if Tags.is_empty() {
695 return false;
696 }
697 let Lower = Tag.to_lowercase();
698 // Explicit tag match always wins (even if `short` would normally mute it).
699 if Tags.iter().any(|T| T == Lower.as_str()) {
700 return true;
701 }
702 if Tags.iter().any(|T| T == "all") {
703 return true;
704 }
705 if Tags.iter().any(|T| T == "short") {
706 return !SHORT_MODE_MUTED_TAGS.iter().any(|Muted| *Muted == Lower.as_str());
707 }
708 false
709}
710
711/// Log a tagged dev message. Only prints if the tag is enabled via
712/// Trace.
713///
714/// In `short` mode: aliases long paths, deduplicates consecutive identical
715/// lines.
716///
717/// **Release-mode behaviour:** the entire body is gated on
718/// `cfg!(debug_assertions)`. Production builds get zero logging by
719/// design - LLVM's dead-code-elimination removes the `format!`,
720/// `IsEnabled` lookup, file-sink write, and dedup-mutex lock so the
721/// hundreds of `dev_log!` callsites in the IPC hot path don't tax
722/// release builds at all. Type-checking still runs against the format
723/// arguments, which catches `{}` placeholder mismatches without
724/// imposing runtime cost.
725#[macro_export]
726macro_rules! dev_log {
727 ($Tag:expr, $($Arg:tt)*) => {
728 if cfg!(debug_assertions) && $crate::IPC::DevLog::IsEnabled($Tag) {
729 let RawMessage = format!($($Arg)*);
730 let TagUpper = $Tag.to_uppercase();
731 if $crate::IPC::DevLog::IsShort() {
732 let Aliased = $crate::IPC::DevLog::AliasPath(&RawMessage);
733 let Key = format!("{}:{}", TagUpper, Aliased);
734 let ShouldPrint = {
735 if let Ok(mut State) = $crate::IPC::DevLog::DEDUP.lock() {
736 if State.LastKey == Key {
737 State.Count += 1;
738 false
739 } else {
740 let PrevCount = State.Count;
741 let HadPrev = !State.LastKey.is_empty();
742 State.LastKey = Key;
743 State.Count = 1;
744 if HadPrev && PrevCount > 1 {
745 let Tail = format!(" (x{})", PrevCount);
746 eprintln!("{}", Tail);
747 $crate::IPC::DevLog::WriteToFile(&Tail);
748 }
749 true
750 }
751 } else {
752 true
753 }
754 };
755 if ShouldPrint {
756 let Formatted = format!("[DEV:{}] {}", TagUpper, Aliased);
757 eprintln!("{}", Formatted);
758 $crate::IPC::DevLog::WriteToFile(&Formatted);
759 }
760 } else {
761 let Formatted = format!("[DEV:{}] {}", TagUpper, RawMessage);
762 eprintln!("{}", Formatted);
763 $crate::IPC::DevLog::WriteToFile(&Formatted);
764 }
765 }
766 };
767}
768
769// ============================================================================
770// OTLP Span Emission - sends spans directly to Jaeger/OTEL collector
771// ============================================================================
772
773static OTLP_AVAILABLE:AtomicBool = AtomicBool::new(true);
774static OTLP_TRACE_ID:OnceLock<String> = OnceLock::new();
775
776fn GetTraceId() -> &'static str {
777 OTLP_TRACE_ID.get_or_init(|| {
778 use std::{
779 collections::hash_map::DefaultHasher,
780 hash::{Hash, Hasher},
781 };
782 let mut H = DefaultHasher::new();
783 std::process::id().hash(&mut H);
784 SystemTime::now()
785 .duration_since(UNIX_EPOCH)
786 .unwrap_or_default()
787 .as_nanos()
788 .hash(&mut H);
789 format!("{:032x}", H.finish() as u128)
790 })
791}
792
793pub fn NowNano() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64 }
794
795/// Emit an OTLP span to the local collector (Jaeger at 127.0.0.1:4318).
796/// Fire-and-forget on a background thread. Stops trying after first failure.
797pub fn EmitOTLPSpan(Name:&str, StartNano:u64, EndNano:u64, Attributes:&[(&str, &str)]) {
798 if !cfg!(debug_assertions) {
799 return;
800 }
801 if !OTLP_AVAILABLE.load(Ordering::Relaxed) {
802 return;
803 }
804
805 let SpanId = format!("{:016x}", rand_u64());
806 let TraceId = GetTraceId().to_string();
807 let SpanName = Name.to_string();
808
809 let AttributesJson:Vec<String> = Attributes
810 .iter()
811 .map(|(K, V)| {
812 format!(
813 r#"{{"key":"{}","value":{{"stringValue":"{}"}}}}"#,
814 K,
815 V.replace('\\', "\\\\").replace('"', "\\\"")
816 )
817 })
818 .collect();
819
820 let IsError = SpanName.contains("error");
821
822 let StatusCode = if IsError { 2 } else { 1 };
823 let Payload = format!(
824 concat!(
825 r#"{{"resourceSpans":[{{"resource":{{"attributes":["#,
826 r#"{{"key":"service.name","value":{{"stringValue":"land-editor-mountain"}}}},"#,
827 r#"{{"key":"service.version","value":{{"stringValue":"0.0.1"}}}}"#,
828 r#"]}},"scopeSpans":[{{"scope":{{"name":"mountain.ipc","version":"1.0.0"}},"#,
829 r#""spans":[{{"traceId":"{}","spanId":"{}","name":"{}","kind":1,"#,
830 r#""startTimeUnixNano":"{}","endTimeUnixNano":"{}","#,
831 r#""attributes":[{}],"status":{{"code":{}}}}}]}}]}}]}}"#,
832 ),
833 TraceId,
834 SpanId,
835 SpanName,
836 StartNano,
837 EndNano,
838 AttributesJson.join(","),
839 StatusCode,
840 );
841
842 // Fire-and-forget on a background thread
843 std::thread::spawn(move || {
844 use std::{
845 io::{Read as IoRead, Write as IoWrite},
846 net::TcpStream,
847 time::Duration,
848 };
849
850 let Ok(mut Stream) = TcpStream::connect_timeout(&"127.0.0.1:4318".parse().unwrap(), Duration::from_millis(200))
851 else {
852 OTLP_AVAILABLE.store(false, Ordering::Relaxed);
853 return;
854 };
855 let _ = Stream.set_write_timeout(Some(Duration::from_millis(200)));
856 let _ = Stream.set_read_timeout(Some(Duration::from_millis(200)));
857
858 let HttpReq = format!(
859 "POST /v1/traces HTTP/1.1\r\nHost: 127.0.0.1:4318\r\nContent-Type: application/json\r\nContent-Length: \
860 {}\r\nConnection: close\r\n\r\n",
861 Payload.len()
862 );
863 if Stream.write_all(HttpReq.as_bytes()).is_err() {
864 return;
865 }
866 if Stream.write_all(Payload.as_bytes()).is_err() {
867 return;
868 }
869 let mut Buf = [0u8; 32];
870 let _ = Stream.read(&mut Buf);
871 if !(Buf.starts_with(b"HTTP/1.1 2") || Buf.starts_with(b"HTTP/1.0 2")) {
872 OTLP_AVAILABLE.store(false, Ordering::Relaxed);
873 }
874 });
875}
876
877fn rand_u64() -> u64 {
878 use std::{
879 collections::hash_map::DefaultHasher,
880 hash::{Hash, Hasher},
881 };
882 let mut H = DefaultHasher::new();
883 std::thread::current().id().hash(&mut H);
884 NowNano().hash(&mut H);
885 H.finish()
886}
887
888/// Convenience macro: emit an OTLP span for an IPC handler.
889/// Usage: `otel_span!("file:readFile", StartNano, &[("path", &SomePath)]);`
890#[macro_export]
891macro_rules! otel_span {
892 ($Name:expr, $Start:expr, $Attrs:expr) => {
893 $crate::IPC::DevLog::EmitOTLPSpan($Name, $Start, $crate::IPC::DevLog::NowNano(), $Attrs)
894 };
895 ($Name:expr, $Start:expr) => {
896 $crate::IPC::DevLog::EmitOTLPSpan($Name, $Start, $crate::IPC::DevLog::NowNano(), &[])
897 };
898}