diff --git a/Cargo.lock b/Cargo.lock index d8942db13..2c88019a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -299,6 +314,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base64" version = "0.22.1" @@ -344,6 +374,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bollard" version = "0.18.1" @@ -918,6 +957,16 @@ dependencies = [ "url", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -1026,6 +1075,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1103,6 +1162,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + [[package]] name = "errno" version = "0.3.14" @@ -1113,6 +1183,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -1145,6 +1225,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exec" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886b70328cba8871bfc025858e1de4be16b1d5088f2ba50b57816f4210672615" +dependencies = [ + "errno 0.2.8", + "libc", +] + [[package]] name = "fabro-agent" version = "0.5.0" @@ -1539,12 +1629,15 @@ dependencies = [ "chrono", "console 0.15.11", "dirs", + "exec", + "fork", "git2", "insta", "mac_address", "md5", "regex", "reqwest 0.12.28", + "sentry", "serde", "serde_json", "tempfile", @@ -1707,6 +1800,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "fork" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05dc8b302e04a1c27f4fe694439ef0f29779ca4edc205b7b58f00db04e29656d" +dependencies = [ + "libc", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1897,6 +1999,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "git2" version = "0.20.4" @@ -2037,6 +2145,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "htmd" version = "0.5.0" @@ -2864,6 +2983,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.1.1" @@ -2937,6 +3065,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.31.2" @@ -3076,6 +3216,174 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -3189,6 +3497,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix 0.30.1", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3766,6 +4090,7 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -3932,6 +4257,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3963,7 +4294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", - "errno", + "errno 0.3.14", "libc", "linux-raw-sys", "windows-sys 0.61.2", @@ -4182,6 +4513,91 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "sentry" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016958f51b96861dead7c1e02290f138411d05e94fad175c8636a835dee6e51e" +dependencies = [ + "httpdate", + "reqwest 0.12.28", + "rustls", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-tracing", + "ureq", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sentry-backtrace" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57712c24e99252ef175b4b06c485294f10ad6bc5b5e1567ff3803ee7a0b7d3f" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba8754ec3b9279e00aa6d64916f211d44202370a1699afde1db2c16cbada089" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8b6dcd4fbae1e3e22b447f32670360b27e31b62ab040f7fb04e0f80c04d92" +dependencies = [ + "once_cell", + "rand 0.8.5", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-tracing" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263f73c757ed7915d3e1e34625eae18cad498a95b4261603d4ce3f87b159a6f0" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a71ed3a389948a6a6d92b98e997a2723ca22f09660c5a7b7388ecd509a70a527" +dependencies = [ + "debugid", + "hex", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.228" @@ -4419,7 +4835,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno", + "errno 0.3.14", "libc", ] @@ -5412,6 +5828,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicase" version = "2.9.0" @@ -5499,6 +5924,21 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -5509,6 +5949,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 20871af9d..ef0725d16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,9 @@ futures-util = "0.3" openssh = "0.11" daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "06033ca", package = "daytona-sdk" } daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "06033ca", package = "daytona-api-client" } +sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } +fork = "0.2" +exec = "0.3" [profile.release] lto = "thin" diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 6d209ca17..36bf56ec7 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -154,6 +154,12 @@ enum Command { /// Path to the JSON event file path: PathBuf, }, + /// Send a queued panic event to Sentry (internal) + #[command(name = "__send_panic", hide = true)] + SendPanic { + /// Path to the JSON event file + path: PathBuf, + }, } #[derive(Subcommand)] @@ -312,6 +318,8 @@ fn detach_run(args: fabro_workflows::cli::RunArgs) -> Result<()> { #[tokio::main] async fn main() { + fabro_util::telemetry::panic::install_panic_hook(); + let start = std::time::Instant::now(); let raw_args: Vec = std::env::args().collect(); @@ -449,6 +457,7 @@ async fn main_inner() -> (String, Result<()>) { SystemCommand::Df(_) => "system df", }, Command::SendAnalytics { .. } => "__send_analytics", + Command::SendPanic { .. } => "__send_panic", }; let command_name = command_name.to_string(); @@ -805,12 +814,12 @@ async fn main_inner() -> (String, Result<()>) { } }, Command::SendAnalytics { path } => { - let result = async { - let json = std::fs::read(&path)?; - let track: fabro_util::telemetry::event::Track = serde_json::from_slice(&json)?; - fabro_util::telemetry::sender::send_to_segment(&track).await - } - .await; + let result = fabro_util::telemetry::sender::send_to_segment(&path).await; + let _ = std::fs::remove_file(&path); + result?; + } + Command::SendPanic { path } => { + let result = fabro_util::telemetry::panic::send_panic_to_sentry(&path).await; let _ = std::fs::remove_file(&path); result?; } diff --git a/lib/crates/fabro-util/Cargo.toml b/lib/crates/fabro-util/Cargo.toml index 055b28b91..55c173d47 100644 --- a/lib/crates/fabro-util/Cargo.toml +++ b/lib/crates/fabro-util/Cargo.toml @@ -27,6 +27,9 @@ anyhow.workspace = true mac_address.workspace = true md5.workspace = true git2.workspace = true +sentry.workspace = true +fork.workspace = true +exec.workspace = true [build-dependencies] toml = "0.8" diff --git a/lib/crates/fabro-util/src/telemetry/mod.rs b/lib/crates/fabro-util/src/telemetry/mod.rs index 6989283cd..3b2db9d5e 100644 --- a/lib/crates/fabro-util/src/telemetry/mod.rs +++ b/lib/crates/fabro-util/src/telemetry/mod.rs @@ -2,8 +2,10 @@ pub mod anonymous_id; pub mod context; pub mod event; pub mod git; +pub mod panic; pub mod sanitize; pub mod sender; +pub mod spawn; use anyhow::Result; use chrono::Utc; diff --git a/lib/crates/fabro-util/src/telemetry/panic.rs b/lib/crates/fabro-util/src/telemetry/panic.rs new file mode 100644 index 000000000..2503d33c8 --- /dev/null +++ b/lib/crates/fabro-util/src/telemetry/panic.rs @@ -0,0 +1,195 @@ +use std::panic::PanicHookInfo; +use std::path::Path; + +use sentry::protocol::{Event, Exception, Mechanism}; + +use super::TelemetryLevel; + +const SENTRY_DSN: Option<&str> = option_env!("SENTRY_DSN"); + +/// Install a panic hook that reports panics to Sentry via a detached subprocess. +/// +/// Must be called early in `main()`, before any other code that might panic. +/// Chains onto the default panic hook so the user still sees the normal output. +pub fn install_panic_hook() { + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + report_panic(info); + default_hook(info); + })); +} + +/// Build a Sentry event from panic info. Exposed for testing. +pub fn build_panic_event(message: &str) -> Event<'static> { + let mut event = Event::new(); + event.level = sentry::Level::Fatal; + + let stacktrace = sentry::integrations::backtrace::current_stacktrace(); + + let exception = Exception { + ty: "panic".into(), + value: Some(message.to_string()), + mechanism: Some(Mechanism { + ty: "panic".into(), + handled: Some(false), + ..Default::default() + }), + stacktrace, + ..Default::default() + }; + + event.exception = sentry::protocol::Values { + values: vec![exception], + }; + + // Add OS context. + event.contexts.insert( + "os".to_string(), + sentry::protocol::Context::Os(Box::new(sentry::protocol::OsContext { + name: Some(std::env::consts::OS.to_string()), + ..Default::default() + })), + ); + + // Set release to the package version. + event.release = Some(crate::version::FABRO_VERSION.into()); + + event +} + +/// Extract a human-readable message from `PanicHookInfo`. +fn panic_message(info: &PanicHookInfo<'_>) -> String { + if let Some(s) = info.payload().downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + } +} + +/// Returns true if this is a "Broken pipe" panic that should be ignored. +/// CLI tools get SIGPIPE from `| head` etc., which is not a real bug. +fn is_broken_pipe(message: &str) -> bool { + message.contains("Broken pipe") +} + +/// Report a panic to Sentry. Called from the panic hook. +fn report_panic(info: &PanicHookInfo<'_>) { + if SENTRY_DSN.is_none() { + return; + } + + let level = super::telemetry_level(); + if level == TelemetryLevel::Off { + return; + } + + let message = panic_message(info); + if is_broken_pipe(&message) { + return; + } + + let event = build_panic_event(&message); + spawn_panic_sender(event); +} + +/// Serialize the Sentry event to a temp file and spawn `fabro __send_panic `. +fn spawn_panic_sender(event: Event<'static>) { + let json = match serde_json::to_vec(&event) { + Ok(j) => j, + Err(_) => return, + }; + + let filename = format!("fabro-panic-{}.json", event.event_id); + super::spawn::spawn_fabro_subcommand("__send_panic", &filename, &json); +} + +/// Send a serialized Sentry panic event. Called by the `__send_panic` subcommand. +/// +/// Reads the JSON event from `path` and sends it to Sentry. +/// No-ops if `SENTRY_DSN` was not set at compile time. +pub async fn send_panic_to_sentry(path: &Path) -> anyhow::Result<()> { + let dsn = SENTRY_DSN.ok_or_else(|| anyhow::anyhow!("SENTRY_DSN not set at compile time"))?; + + let json = std::fs::read(path)?; + let event: Event<'static> = serde_json::from_slice(&json)?; + + let guard = sentry::init((dsn, sentry::ClientOptions::default())); + + sentry::capture_event(event); + + // Flush before dropping the guard so the event is sent. + guard.close(Some(std::time::Duration::from_secs(5))); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_panic_event_structure() { + let event = build_panic_event("test panic message"); + + assert_eq!(event.level, sentry::Level::Fatal); + assert_eq!(event.exception.values.len(), 1); + + let exc = &event.exception.values[0]; + assert_eq!(exc.ty, "panic"); + assert_eq!(exc.value.as_deref(), Some("test panic message")); + + let mech = exc.mechanism.as_ref().unwrap(); + assert_eq!(mech.ty, "panic"); + assert_eq!(mech.handled, Some(false)); + + // Stacktrace should be present. + assert!(exc.stacktrace.is_some()); + + // OS context should be present. + assert!(event.contexts.contains_key("os")); + + // Release should be set. + assert!(event.release.is_some()); + } + + #[test] + fn broken_pipe_is_filtered() { + assert!(is_broken_pipe("Broken pipe (os error 32)")); + assert!(is_broken_pipe("connection reset: Broken pipe")); + assert!(!is_broken_pipe("index out of bounds")); + } + + #[test] + fn report_panic_noop_when_telemetry_off() { + // Set telemetry off and verify report_panic doesn't panic itself. + std::env::set_var("FABRO_TELEMETRY", "off"); + // We can't easily create a PanicHookInfo, so test the individual pieces: + assert_eq!(super::super::telemetry_level(), TelemetryLevel::Off); + std::env::remove_var("FABRO_TELEMETRY"); + } + + #[test] + fn send_panic_noops_without_dsn() { + // SENTRY_DSN is not set at compile time in tests, so this should error. + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(send_panic_to_sentry(Path::new("/nonexistent"))); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("SENTRY_DSN not set")); + } + + #[test] + fn event_round_trips_through_json() { + let event = build_panic_event("roundtrip test"); + let json = serde_json::to_vec(&event).unwrap(); + let deserialized: Event<'static> = serde_json::from_slice(&json).unwrap(); + assert_eq!(deserialized.level, sentry::Level::Fatal); + assert_eq!(deserialized.exception.values.len(), 1); + assert_eq!( + deserialized.exception.values[0].value.as_deref(), + Some("roundtrip test") + ); + } +} diff --git a/lib/crates/fabro-util/src/telemetry/sender.rs b/lib/crates/fabro-util/src/telemetry/sender.rs index a6b10dc14..bf89ce795 100644 --- a/lib/crates/fabro-util/src/telemetry/sender.rs +++ b/lib/crates/fabro-util/src/telemetry/sender.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use base64::engine::general_purpose::STANDARD; use base64::Engine; @@ -17,48 +19,35 @@ pub fn send(track: Track) { return; } - if let Err(err) = spawn_sender(track) { - tracing::debug!(%err, "telemetry: failed to spawn analytics sender"); - } + spawn_sender(track); } -fn spawn_sender(track: Track) -> std::io::Result<()> { - let tmp_dir = dirs::home_dir() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"))? - .join(".fabro") - .join("tmp"); +fn spawn_sender(track: Track) { + let json = match serde_json::to_vec(&track) { + Ok(j) => j, + Err(_) => return, + }; - std::fs::create_dir_all(&tmp_dir)?; - - let path = tmp_dir.join(format!("fabro-event-{}.json", track.message_id)); - let json = serde_json::to_vec(&track) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - std::fs::write(&path, json)?; - - let exe = std::env::current_exe()?; - std::process::Command::new(exe) - .arg("__send_analytics") - .arg(&path) - .env("FABRO_TELEMETRY", "off") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn()?; - - Ok(()) + let filename = format!("fabro-event-{}.json", track.message_id); + super::spawn::spawn_fabro_subcommand("__send_analytics", &filename, &json); } -/// Sends a track event to Segment. Called by the `__send_analytics` subcommand. -pub async fn send_to_segment(track: &Track) -> anyhow::Result<()> { +/// Reads a serialized track event from `path` and sends it to Segment. +/// Called by the `__send_analytics` subcommand. +/// No-ops if `SEGMENT_WRITE_KEY` was not set at compile time. +pub async fn send_to_segment(path: &Path) -> anyhow::Result<()> { let write_key = SEGMENT_WRITE_KEY .ok_or_else(|| anyhow::anyhow!("SEGMENT_WRITE_KEY not set at compile time"))?; + let json = std::fs::read(path)?; + let track: Track = serde_json::from_slice(&json)?; + let auth = STANDARD.encode(format!("{write_key}:")); let resp = reqwest::Client::new() .post(SEGMENT_API_URL) .header("Authorization", format!("Basic {auth}")) - .json(track) + .json(&track) .send() .await?; diff --git a/lib/crates/fabro-util/src/telemetry/spawn.rs b/lib/crates/fabro-util/src/telemetry/spawn.rs new file mode 100644 index 000000000..8626def60 --- /dev/null +++ b/lib/crates/fabro-util/src/telemetry/spawn.rs @@ -0,0 +1,154 @@ +/// Spawn a fully detached subprocess that survives parent exit and terminal close. +/// +/// On Unix this uses the double-fork pattern (fork → setsid → close_fd → fork → exec) +/// so the child is reparented to init and cannot receive SIGHUP from the terminal. +/// +/// `args` is the full argv (program + arguments). +/// `env` is a list of (key, value) pairs to set in the child environment. +pub fn spawn_detached(args: &[&str], env: &[(&str, &str)]) { + if args.is_empty() { + return; + } + + #[cfg(unix)] + { + spawn_detached_unix(args, env); + } + + #[cfg(windows)] + { + spawn_detached_windows(args, env); + } +} + +#[cfg(unix)] +fn spawn_detached_unix(args: &[&str], env: &[(&str, &str)]) { + use fork::{fork, setsid, Fork}; + + // First fork — parent returns immediately. + match fork() { + Ok(Fork::Parent(_)) => {} + Ok(Fork::Child) => { + // Create a new session so we detach from the controlling terminal. + let _ = setsid(); + + // Second fork — the intermediate child exits so the grandchild + // is reparented to init/PID 1 and can never reacquire a terminal. + match fork() { + Ok(Fork::Parent(_)) => { + // Intermediate child exits immediately. + std::process::exit(0); + } + Ok(Fork::Child) => { + // Close stdin/stdout/stderr so the grandchild doesn't hold + // any references to the original terminal. + let _ = fork::close_fd(); + + // Set environment variables before exec. + for (key, value) in env { + std::env::set_var(key, value); + } + + // Replace the process with the target command. + let _err = exec::execvp(args[0], args); + // If execvp returns, it failed. stderr is closed so we can't log. + std::process::exit(1); + } + Err(_) => std::process::exit(1), + } + } + Err(_) => { + tracing::debug!("spawn_detached: first fork failed"); + } + } +} + +#[cfg(windows)] +fn spawn_detached_windows(args: &[&str], env: &[(&str, &str)]) { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x00000008; + + let mut cmd = std::process::Command::new(args[0]); + if args.len() > 1 { + cmd.args(&args[1..]); + } + for (key, value) in env { + cmd.env(key, value); + } + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .creation_flags(DETACHED_PROCESS); + + if let Err(err) = cmd.spawn() { + tracing::debug!(%err, "spawn_detached: failed to spawn on Windows"); + } +} + +/// Serialize data as JSON to a temp file and spawn `fabro ` +/// as a fully detached subprocess. Sets `FABRO_TELEMETRY=off` to prevent recursion. +/// +/// This is the shared pattern used by both analytics and panic senders. +/// No-ops silently if the exe path can't be resolved or the temp file can't be written. +pub fn spawn_fabro_subcommand(subcommand: &str, filename: &str, json: &[u8]) { + let tmp_dir = match dirs::home_dir() { + Some(h) => h.join(".fabro").join("tmp"), + None => return, + }; + if std::fs::create_dir_all(&tmp_dir).is_err() { + return; + } + let path = tmp_dir.join(filename); + if std::fs::write(&path, json).is_err() { + return; + } + + let path_str = match path.to_str() { + Some(s) => s.to_string(), + None => return, + }; + + let exe = match std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + { + Some(e) => e, + None => return, + }; + + spawn_detached( + &[&exe, subcommand, &path_str], + &[("FABRO_TELEMETRY", "off")], + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spawn_detached_empty_args_is_noop() { + // Should not panic or do anything. + spawn_detached(&[], &[]); + } + + #[cfg(unix)] + #[test] + fn spawn_detached_unix_creates_marker_file() { + // Spawn a detached `touch ` and verify the file appears. + let tmp = std::env::temp_dir().join("fabro-spawn-detached-test-marker"); + let _ = std::fs::remove_file(&tmp); + + let tmp_str = tmp.to_str().unwrap(); + spawn_detached(&["touch", tmp_str], &[]); + + // Wait a bit for the detached process to complete. + std::thread::sleep(std::time::Duration::from_millis(500)); + + assert!( + tmp.exists(), + "detached process should have created the marker file" + ); + std::fs::remove_file(&tmp).ok(); + } +}