From 4915fc537899b9d3d3f313f055626adfd0ce0b0d Mon Sep 17 00:00:00 2001
From: "brynary-fabro[bot]"
<265161896+brynary-fabro[bot]@users.noreply.github.com>
Date: Mon, 16 Mar 2026 22:00:11 -0400
Subject: [PATCH] Add Sentry panic reporting to fabro CLI (#35)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR adds Sentry-based panic reporting to the fabro CLI and improves
the reliability of all background telemetry senders. When the CLI
panics, a Sentry event is serialized to a temp file and uploaded by a
fully detached subprocess, giving visibility into crashes that would
otherwise go unnoticed.
The core infrastructure change is a new `spawn_detached` utility that
uses the double-fork pattern on Unix (fork → setsid → close_fd → fork →
exec) to ensure background subprocesses survive parent exit and terminal
close. This replaces the previous simple `Command::spawn()` approach
used by the analytics sender, which was unreliable since the child could
be killed when the parent exits. Both the analytics sender
(`__send_analytics`) and the new panic sender (`__send_panic`) now share
this `spawn_fabro_subcommand` helper.
The panic module installs a hook early in `main()` that captures panic
info, builds a Sentry event with exception details, stacktrace, and OS
context, then spawns a detached `fabro __send_panic` subprocess to
upload it. It respects the telemetry level setting (no-ops when off),
prevents recursion by setting `FABRO_TELEMETRY=off` in the child,
filters benign "Broken pipe" panics from `| head` usage, and uses a
compile-time `SENTRY_DSN` so dev builds without the DSN set are
unaffected.
### Fabro Details
Ran 10 stages in 32m 34s for $8.13
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $2.35 | 0 |
| simplify_opus | 0s | $2.23 | 0 |
| simplify_gemini | 0s | $1.75 | 0 |
| simplify_gpt | 0s | $1.80 | 0 |
| verify | 0s | – | 0 |
| fmt | 0s | – | 0 |
| **Total** | **32m 34s** | **$8.13** | **0** |
Ran ImplementAndSimplify.fabro (13 nodes and 16
edges)
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gemini [label="Simplify (Gemini)", prompt="@prompts/simplify.md", model="gemini-3.1-pro-preview-customtools"]
simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", goal_gate=true, max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gemini -> simplify_gpt -> verify
verify -> fmt [condition="outcome=success"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
---
Cargo.lock | 445 +++++++++++++++++-
Cargo.toml | 3 +
lib/crates/fabro-cli/src/main.rs | 21 +-
lib/crates/fabro-util/Cargo.toml | 3 +
lib/crates/fabro-util/src/telemetry/mod.rs | 2 +
lib/crates/fabro-util/src/telemetry/panic.rs | 195 ++++++++
lib/crates/fabro-util/src/telemetry/sender.rs | 47 +-
lib/crates/fabro-util/src/telemetry/spawn.rs | 154 ++++++
8 files changed, 833 insertions(+), 37 deletions(-)
create mode 100644 lib/crates/fabro-util/src/telemetry/panic.rs
create mode 100644 lib/crates/fabro-util/src/telemetry/spawn.rs
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();
+ }
+}