Add Sentry panic reporting to fabro CLI (#35)

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

<details>
<summary>Ran 10 stages in 32m 34s for $8.13</summary>

| 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** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (13 nodes and 16
edges)</summary>

```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
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
brynary-fabro[bot] 2026-03-16 22:00:11 -04:00 • committed by GitHub
parent 139bf5cbef
commit 4915fc5378
8 changed files with 833 additions and 37 deletions

445
Cargo.lock generated
View file

@ -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]]

View file

@ -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"

View file

@ -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<String> = 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?;
}

View file

@ -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"

View file

@ -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;

View file

@ -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::<String>() {
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 <path>`.
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")
);
}
}

View file

@ -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?;

View file

@ -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 <subcommand> <path>`
/// 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 <marker>` 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();
}
}