1use std::fs::{self, File, OpenOptions};
18use std::io::{self, Write};
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Mutex, OnceLock};
22
23use chrono::Utc;
24use serde::Serialize;
25use serde_json::json;
26use tracing::{Event, Subscriber};
27use tracing_subscriber::fmt::format::FmtSpan;
28use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
29use tracing_subscriber::util::SubscriberInitExt;
30use tracing_subscriber::{EnvFilter, Registry};
31
32static STATE: OnceLock<LoggingState> = OnceLock::new();
34
35pub(crate) static PANIC_CLEANUP: Mutex<Option<Arc<dyn Fn() + Send + Sync>>> = Mutex::new(None);
37
38static INITIALIZED: AtomicBool = AtomicBool::new(false);
40
41pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
43
44#[derive(Debug, Clone)]
48pub struct LogDirs {
49 pub root: PathBuf,
50 pub logs: PathBuf,
51 pub crash: PathBuf,
52 pub debug: PathBuf,
53}
54
55impl LogDirs {
56 pub fn from_data_dir(data_dir: impl Into<PathBuf>) -> Self {
57 let root = data_dir.into();
58 Self {
59 logs: root.join("logs"),
60 crash: root.join("crash"),
61 debug: root.join("debug"),
62 root,
63 }
64 }
65
66 pub fn ensure(&self) -> io::Result<()> {
68 fs::create_dir_all(&self.logs)?;
69 fs::create_dir_all(&self.crash)?;
70 fs::create_dir_all(&self.debug)?;
71 Ok(())
72 }
73
74 pub fn unified_jsonl(&self) -> PathBuf {
75 self.logs.join("unified.jsonl")
76 }
77
78 pub fn crash_report_path(&self, stamp: &str) -> PathBuf {
79 self.crash.join(format!("crash-{stamp}.txt"))
80 }
81}
82
83#[derive(Debug, Clone, Serialize)]
87pub struct LogEvent {
88 pub ts: String,
89 pub src: String,
90 pub pid: u32,
91 pub ver: String,
92 pub lvl: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub sid: Option<String>,
95 pub msg: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub ctx: Option<serde_json::Value>,
98}
99
100impl LogEvent {
101 pub fn new(src: impl Into<String>, lvl: impl Into<String>, msg: impl Into<String>) -> Self {
102 Self {
103 ts: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
104 src: src.into(),
105 pid: std::process::id(),
106 ver: VERSION.to_string(),
107 lvl: lvl.into(),
108 sid: None,
109 msg: msg.into(),
110 ctx: None,
111 }
112 }
113
114 pub fn with_sid(mut self, sid: impl Into<String>) -> Self {
115 self.sid = Some(sid.into());
116 self
117 }
118
119 pub fn with_ctx(mut self, ctx: serde_json::Value) -> Self {
120 self.ctx = Some(ctx);
121 self
122 }
123}
124
125pub fn append_jsonl(path: &Path, event: &LogEvent) -> io::Result<()> {
127 ensure_parent(path)?;
128 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
129 serde_json::to_writer(&mut file, event).map_err(io::Error::other)?;
130 file.write_all(b"\n")?;
131 file.flush()?;
132 Ok(())
133}
134
135pub(crate) fn ensure_parent(path: &Path) -> io::Result<()> {
136 match path.parent() {
137 Some(parent) => fs::create_dir_all(parent),
138 None => Ok(()),
139 }
140}
141
142pub fn emit(src: &str, lvl: &str, msg: &str, ctx: Option<serde_json::Value>) {
144 emit_to_state(STATE.get(), src, lvl, msg, None, ctx);
145}
146
147pub fn emit_sid(
149 src: &str,
150 lvl: &str,
151 msg: &str,
152 sid: Option<&str>,
153 ctx: Option<serde_json::Value>,
154) {
155 emit_to_state(STATE.get(), src, lvl, msg, sid, ctx);
156}
157
158fn emit_to_state(
159 state: Option<&LoggingState>,
160 src: &str,
161 lvl: &str,
162 msg: &str,
163 sid: Option<&str>,
164 ctx: Option<serde_json::Value>,
165) {
166 let Some(state) = state else {
167 return;
168 };
169 let mut event = LogEvent::new(src, lvl, msg);
170 event.pid = state.pid;
171 event.ver = state.version.clone();
172 event.sid = sid.map(|s| s.to_string());
173 event.ctx = ctx;
174 let _ = write_jsonl_locked(&state.unified, &event);
175}
176
177#[cfg(test)]
179pub(crate) fn emit_uninitialized_for_test() {
180 emit_to_state(None, "test", "info", "uninitialized", None, None);
181}
182
183pub(crate) fn write_jsonl_locked(file: &Mutex<File>, event: &LogEvent) -> io::Result<()> {
184 let mut guard = lock_log_file(file)?;
185 serde_json::to_writer(&mut *guard, event).map_err(io::Error::other)?;
186 guard.write_all(b"\n")?;
187 guard.flush()?;
188 Ok(())
189}
190
191pub(crate) fn lock_log_file(file: &Mutex<File>) -> io::Result<std::sync::MutexGuard<'_, File>> {
192 file.lock()
193 .map_err(|e| io::Error::other(format!("log lock poisoned: {e}")))
194}
195
196#[derive(Debug, Clone)]
200pub struct InitOptions {
201 pub data_dir: PathBuf,
203 pub log_level: Option<String>,
205 pub log_file: Option<PathBuf>,
207 pub debug: bool,
209 pub with_stderr: bool,
211}
212
213impl Default for InitOptions {
214 fn default() -> Self {
215 Self {
216 data_dir: PathBuf::from("."),
217 log_level: None,
218 log_file: None,
219 debug: false,
220 with_stderr: true,
221 }
222 }
223}
224
225#[derive(Debug, Clone)]
227pub struct LoggingHandle {
228 pub dirs: LogDirs,
229 pub debug_log: Option<PathBuf>,
231}
232
233struct LoggingState {
234 dirs: LogDirs,
235 unified: Mutex<File>,
236 pid: u32,
237 version: String,
238}
239
240pub fn init(opts: InitOptions) -> crate::Result<LoggingHandle> {
243 if let Some(state) = STATE.get() {
244 return Ok(LoggingHandle {
245 dirs: state.dirs.clone(),
246 debug_log: None,
247 });
248 }
249
250 let dirs = LogDirs::from_data_dir(&opts.data_dir);
251 dirs.ensure().map_err(crate::Error::Io)?;
252
253 let unified_path = dirs.unified_jsonl();
254 let unified_file = OpenOptions::new()
255 .create(true)
256 .append(true)
257 .open(&unified_path)
258 .map_err(crate::Error::Io)?;
259
260 let pid = std::process::id();
261 let state = LoggingState {
262 dirs: dirs.clone(),
263 unified: Mutex::new(unified_file),
264 pid,
265 version: VERSION.to_string(),
266 };
267
268 let debug_log = resolve_debug_log_path(&dirs, &opts)?;
270
271 let filter = build_env_filter(opts.log_level.as_deref());
272
273 let jsonl_layer = JsonlLayer {
275 file: Arc::new(Mutex::new(
276 OpenOptions::new()
277 .create(true)
278 .append(true)
279 .open(&unified_path)
280 .map_err(crate::Error::Io)?,
281 )),
282 pid,
283 version: VERSION.to_string(),
284 };
285
286 let registry = Registry::default().with(filter).with(jsonl_layer);
287
288 let file_layer = choose_debug_file(debug_log.as_deref())?.map(fmt_file_layer);
290
291 let stderr_layer = maybe_layer(opts.with_stderr, stderr_fmt_layer());
292
293 let result = registry.with(file_layer).with(stderr_layer).try_init();
295 note_try_init(result.is_ok());
296
297 let _ = STATE.set(state);
298 INITIALIZED.store(true, Ordering::SeqCst);
299
300 install_panic_hook_inner(dirs.clone());
301
302 emit(
303 "whycodes",
304 "info",
305 "logging.init",
306 Some(json!({
307 "unified": unified_path.display().to_string(),
308 "debug_log": debug_log.as_ref().map(|p| p.display().to_string()),
309 "with_stderr": opts.with_stderr,
310 "debug": opts.debug,
311 })),
312 );
313
314 Ok(LoggingHandle { dirs, debug_log })
315}
316
317pub(crate) fn resolve_debug_log_path(
318 dirs: &LogDirs,
319 opts: &InitOptions,
320) -> crate::Result<Option<PathBuf>> {
321 if let Some(ref p) = opts.log_file {
322 create_parent_dir(p)?;
323 link_latest(dirs, p);
324 return Ok(Some(p.clone()));
325 }
326 if opts.debug {
327 let stamp = Utc::now().format("%Y%m%dT%H%M%S");
328 let path = dirs
329 .debug
330 .join(format!("whycodes-{}-{}.log", std::process::id(), stamp));
331 let _ = OpenOptions::new()
333 .create(true)
334 .append(true)
335 .open(&path)
336 .map_err(crate::Error::Io)?;
337 link_latest(dirs, &path);
338 return Ok(Some(path));
339 }
340 Ok(None)
341}
342
343fn link_latest(dirs: &LogDirs, target: &Path) {
344 let latest = dirs.debug.join("latest.log");
345 let _ = fs::remove_file(&latest);
346 #[cfg(unix)]
347 {
348 symlink_or_copy(target, &latest);
349 }
350 #[cfg(not(unix))]
351 {
352 let _ = fs::write(dirs.debug.join("latest.path"), target.display().to_string());
353 }
354}
355
356pub(crate) fn open_append(path: &Path) -> crate::Result<File> {
357 OpenOptions::new()
358 .create(true)
359 .append(true)
360 .open(path)
361 .map_err(crate::Error::Io)
362}
363
364pub(crate) fn create_parent_dir(p: &Path) -> crate::Result<()> {
365 if let Some(parent) = p.parent()
366 && !parent.as_os_str().is_empty()
367 {
368 fs::create_dir_all(parent).map_err(crate::Error::Io)?;
369 }
370 Ok(())
371}
372
373#[cfg(unix)]
374pub(crate) fn symlink_or_copy(target: &Path, latest: &Path) {
375 if std::os::unix::fs::symlink(target, latest).is_err() {
376 let _ = fs::copy(target, latest);
377 }
378}
379
380#[cfg(all(test, not(unix)))]
381mod windows_symlink_helper {
382 use super::*;
383
384 pub(crate) fn symlink_or_copy(target: &Path, latest: &Path) {
385 if fs::copy(target, latest).is_err() {
386 }
388 }
389}
390
391#[cfg(all(test, not(unix)))]
392pub(crate) use windows_symlink_helper::symlink_or_copy;
393
394pub(crate) fn fmt_file_layer<S>(
395 file: File,
396) -> tracing_subscriber::fmt::Layer<
397 S,
398 tracing_subscriber::fmt::format::DefaultFields,
399 tracing_subscriber::fmt::format::Format,
400 Mutex<File>,
401>
402where
403 S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
404{
405 tracing_subscriber::fmt::layer()
406 .with_ansi(false)
407 .with_writer(Mutex::new(file))
408 .with_span_events(FmtSpan::NONE)
409 .with_target(true)
410}
411
412pub(crate) fn choose_debug_file(path: Option<&Path>) -> crate::Result<Option<File>> {
413 match path {
414 Some(p) => Ok(Some(open_append(p)?)),
415 None => Ok(None),
416 }
417}
418
419pub(crate) fn emit_panic_report(result: io::Result<PathBuf>, payload: &str) {
420 eprintln!("{}", crash_user_message(&result));
421 let Ok(path) = result else {
422 return;
423 };
424 emit(
425 "whycodes",
426 "error",
427 "panic",
428 Some(json!({
429 "path": path.display().to_string(),
430 "payload": payload,
431 })),
432 );
433}
434
435pub(crate) fn format_location(loc: Option<&std::panic::Location<'_>>) -> Option<String> {
436 loc.map(|loc| format!("location: {}:{}:{}\n", loc.file(), loc.line(), loc.column()))
437}
438
439pub(crate) fn crash_user_message(result: &io::Result<PathBuf>) -> String {
440 match result {
441 Ok(path) => format!("\nwhycodes crashed — report written to {}", path.display()),
442 Err(e) => format!("\nwhycodes crashed — failed to write crash report: {e}"),
443 }
444}
445
446pub(crate) fn clean_debug_value(s: String) -> String {
447 if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
448 return s[1..s.len() - 1].to_string();
449 }
450 s
451}
452
453pub(crate) fn build_env_filter(explicit: Option<&str>) -> EnvFilter {
454 if let Ok(filter) = EnvFilter::try_from_default_env() {
456 return filter;
457 }
458 if let Some(level) = explicit
459 && let Ok(f) = EnvFilter::try_new(level)
460 {
461 return f;
462 }
463 if let Ok(level) = std::env::var("WHYCODES_LOG_LEVEL")
464 && let Ok(f) = EnvFilter::try_new(&level)
465 {
466 return f;
467 }
468 EnvFilter::new("info")
469}
470
471pub(crate) fn maybe_layer<T>(on: bool, layer: T) -> Option<T> {
472 match on {
473 true => Some(layer),
474 false => None,
475 }
476}
477
478pub(crate) fn note_try_init(ok: bool) {
479 warn_already_set((!ok).then(|| "already".into()));
480}
481
482pub(crate) fn event_message(message: Option<String>, fallback: &str) -> String {
483 match message {
484 Some(m) => m,
485 None => fallback.to_string(),
486 }
487}
488
489pub(crate) fn warn_already_set(err: Option<String>) {
490 if let Some(e) = err {
491 tracing::warn!("tracing subscriber already set: {e}");
492 }
493}
494
495pub(crate) fn stderr_fmt_layer<S>() -> impl Layer<S>
496where
497 S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
498{
499 tracing_subscriber::fmt::layer()
500 .with_writer(io::stderr)
501 .with_span_events(FmtSpan::NONE)
502 .with_target(true)
503}
504
505pub fn is_initialized() -> bool {
507 INITIALIZED.load(Ordering::SeqCst)
508}
509
510pub fn dirs() -> Option<LogDirs> {
512 STATE.get().map(|s| s.dirs.clone())
513}
514
515pub fn set_panic_cleanup(f: impl Fn() + Send + Sync + 'static) {
519 *recover_cleanup() = Some(Arc::new(f));
520}
521
522pub fn clear_panic_cleanup() {
524 *recover_cleanup() = None;
525}
526
527fn recover_cleanup() -> std::sync::MutexGuard<'static, Option<Arc<dyn Fn() + Send + Sync>>> {
528 recover_lock(PANIC_CLEANUP.lock())
529}
530
531pub(crate) fn recover_lock<T>(res: std::sync::LockResult<T>) -> T {
532 match res {
533 Ok(g) => g,
534 Err(p) => p.into_inner(),
535 }
536}
537
538#[cfg(test)]
539pub(crate) fn poison_panic_cleanup() {
540 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
541 let _g = PANIC_CLEANUP.lock().unwrap();
542 panic!("poison");
543 }));
544}
545
546pub(crate) fn run_panic_cleanup() {
551 let slot = match PANIC_CLEANUP.try_lock() {
552 Ok(g) => g,
553 Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
554 Err(std::sync::TryLockError::WouldBlock) => return,
555 };
556 if let Some(ref cleanup) = *slot {
557 cleanup();
558 }
559}
560
561fn install_panic_hook_inner(dirs: LogDirs) {
562 let default_hook = std::panic::take_hook();
563 std::panic::set_hook(Box::new(move |info| {
564 run_panic_cleanup();
566
567 emit_panic_report(write_crash_report(&dirs, info), &panic_message(info));
568
569 default_hook(info);
570 }));
571}
572
573pub fn write_crash_report(
575 dirs: &LogDirs,
576 info: &std::panic::PanicHookInfo<'_>,
577) -> io::Result<PathBuf> {
578 dirs.ensure()?;
579 let stamp = Utc::now().format("%Y%m%dT%H%M%S%.3f");
580 let path = dirs.crash_report_path(&stamp.to_string());
581 let body = format_crash_report(info);
582 fs::write(&path, body)?;
583 Ok(path)
584}
585
586pub fn format_crash_report(info: &std::panic::PanicHookInfo<'_>) -> String {
588 let mut out = String::new();
589 out.push_str("whycodes crash report\n");
590 out.push_str(&format!("version: {VERSION}\n"));
591 out.push_str(&format!("pid: {}\n", std::process::id()));
592 out.push_str(&format!(
593 "time: {}\n",
594 Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
595 ));
596 if let Some(line) = format_location(info.location()) {
597 out.push_str(&line);
598 }
599 out.push_str(&format!("message: {}\n", panic_message(info)));
600 out.push_str(&format!(
601 "RUST_BACKTRACE: {}\n",
602 std::env::var("RUST_BACKTRACE").unwrap_or_else(|_| "unset".into())
603 ));
604 out.push_str("\nbacktrace:\n");
606 out.push_str(&format!(
607 "{:?}\n",
608 std::backtrace::Backtrace::force_capture()
609 ));
610 out
611}
612
613pub(crate) fn panic_message(info: &std::panic::PanicHookInfo<'_>) -> String {
614 info.payload_as_str()
615 .map(str::to_string)
616 .unwrap_or_else(|| "Box<dyn Any>".to_string())
617}
618
619struct JsonlLayer {
622 file: Arc<Mutex<File>>,
623 pid: u32,
624 version: String,
625}
626
627impl JsonlLayer {
628 pub(crate) fn record(&self, event: &Event<'_>) {
629 let mut visitor = JsonVisitor::default();
630 event.record(&mut visitor);
631
632 let meta = event.metadata();
633 let lvl = meta.level().as_str().to_ascii_lowercase();
634 let src = meta.target().to_string();
635 let msg = event_message(visitor.message.take(), meta.name());
636
637 let ctx_map = visitor.fields;
638 let log_event = LogEvent {
639 ts: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
640 src,
641 pid: self.pid,
642 ver: self.version.clone(),
643 lvl,
644 sid: visitor.sid,
645 msg,
646 ctx: if ctx_map.is_empty() {
647 None
648 } else {
649 Some(serde_json::Value::Object(ctx_map))
650 },
651 };
652
653 let _ = write_jsonl_locked(&self.file, &log_event);
654 }
655}
656
657impl<S> Layer<S> for JsonlLayer
658where
659 S: Subscriber,
660{
661 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
662 self.record(event);
663 }
664}
665
666#[derive(Default)]
667pub(crate) struct JsonVisitor {
668 pub(crate) message: Option<String>,
669 pub(crate) sid: Option<String>,
670 pub(crate) fields: serde_json::Map<String, serde_json::Value>,
671}
672
673impl JsonVisitor {
674 pub(crate) fn assign_str(&mut self, name: &str, value: String) {
675 if name == "message" {
676 self.message = Some(value);
677 } else if name == "sid" {
678 self.sid = Some(value);
679 } else {
680 self.fields.insert(name.to_string(), json!(value));
681 }
682 }
683}
684
685impl tracing::field::Visit for JsonVisitor {
686 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
687 self.assign_str(field.name(), clean_debug_value(format!("{value:?}")));
688 }
689
690 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
691 self.assign_str(field.name(), value.to_string());
692 }
693
694 fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
695 self.fields.insert(field.name().to_string(), json!(value));
696 }
697
698 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
699 self.fields.insert(field.name().to_string(), json!(value));
700 }
701
702 fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
703 self.fields.insert(field.name().to_string(), json!(value));
704 }
705}
706
707#[cfg(test)]
710mod tests {
711 use super::*;
712
713 #[test]
714 fn log_event_dirs_and_jsonl() {
715 let ev = LogEvent::new("src", "info", "hello")
716 .with_sid("sid-1")
717 .with_ctx(serde_json::json!({"k": 1}));
718 assert_eq!(ev.src, "src");
719 assert_eq!(ev.sid.as_deref(), Some("sid-1"));
720 assert!(ev.ctx.is_some());
721
722 let dir = tempfile::tempdir().unwrap();
723 let dirs = LogDirs::from_data_dir(dir.path());
724 dirs.ensure().unwrap();
725 assert!(dirs.unified_jsonl().ends_with("unified.jsonl"));
726 assert!(
727 dirs.crash_report_path("stamp")
728 .to_string_lossy()
729 .contains("stamp")
730 );
731 append_jsonl(&dirs.unified_jsonl(), &ev).unwrap();
732 let written = std::fs::read_to_string(dirs.unified_jsonl()).unwrap();
733 assert!(written.contains("hello"));
734 assert_eq!(clean_debug_value(r#""quoted""#.into()), "quoted");
735 assert_eq!(clean_debug_value("plain".into()), "plain");
736 assert_eq!(clean_debug_value("\"open".into()), "\"open");
737 assert_eq!(maybe_layer(true, 7), Some(7));
739 assert_eq!(maybe_layer(false, 7), None);
740 }
741}