pub mod frames;
pub mod images;
pub mod payload;
pub mod scrub;
use std::panic::Location;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwapOption;
use serde_json::{Map, Value};
use self::payload::{DebugImage, Exception, Mechanism, Stacktrace};
use super::consent::{resolve_crash, CrashResolved};
use super::network::{environment, key_is_present, posthog_host, posthog_key, release};
const CRASH_POST_TIMEOUT: Duration = Duration::from_secs(3);
pub const ENV_DEVELOPMENT: &str = "development";
pub struct CrashConfig {
pub egress: crate::egress::EgressConfig,
pub project_key: String,
pub host: String,
pub distinct_id: String,
pub environment: &'static str,
pub release: &'static str,
filter: crate::privacy::PrivacyFilter,
}
impl CrashConfig {
pub fn new(
egress: crate::egress::EgressConfig,
project_key: String,
host: String,
distinct_id: String,
environment: &'static str,
release: &'static str,
) -> Self {
Self {
egress,
project_key,
host,
distinct_id,
environment,
release,
filter: crate::privacy::PrivacyFilter::new(&[]),
}
}
pub fn filter(&self) -> &crate::privacy::PrivacyFilter {
&self.filter
}
pub fn resolve(openlatch_dir: &Path, egress: crate::egress::EgressConfig) -> Option<Self> {
let decision = current_state(openlatch_dir);
if !decision.enabled() {
return None;
}
Some(Self::new(
egress,
posthog_key().to_string(),
posthog_host(),
crate::config::sniff_agent_id(openlatch_dir)
.unwrap_or_else(|| "agt_unknown".to_string()),
environment(),
release(),
))
}
}
pub fn current_state(openlatch_dir: &Path) -> CrashResolved {
resolve_crash(&openlatch_dir.join("config.toml"), key_is_present())
}
pub const fn build_includes_crash_report() -> bool {
true
}
static PROCESS_SCOPE: ArcSwapOption<Map<String, Value>> = ArcSwapOption::const_empty();
pub fn set_cli_scope(command: &str) {
let mut props = Map::new();
props.insert("process_type".into(), Value::from("cli"));
props.insert("command".into(), Value::from(command));
set_process_scope(props);
}
pub fn set_daemon_scope(port: u16, pid: u32) {
let mut props = Map::new();
props.insert("process_type".into(), Value::from("daemon"));
props.insert("port".into(), Value::from(port));
props.insert("pid".into(), Value::from(pid));
set_process_scope(props);
}
fn set_process_scope(props: Map<String, Value>) {
PROCESS_SCOPE.store(Some(Arc::new(props)));
}
fn process_scope() -> Option<Arc<Map<String, Value>>> {
PROCESS_SCOPE.load_full()
}
static HOOK_INSTALLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn install_panic_hook(cfg: CrashConfig) -> bool {
use std::sync::atomic::Ordering;
if HOOK_INSTALLED.swap(true, Ordering::SeqCst) {
return false;
}
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
previous(info);
capture_panic(info, &cfg);
}));
true
}
fn capture_panic(info: &std::panic::PanicHookInfo<'_>, cfg: &CrashConfig) {
if !should_send(cfg) {
tracing::debug!("crash report suppressed: development build, no host override");
return;
}
let message = payload_message(info);
let location = info.location();
let bt = backtrace::Backtrace::new();
let modules = images::collect_loaded_modules();
let captured = frames::build_frames(&bt, &modules);
let mut exception = Exception {
exception_type: "panic".to_string(),
value: message,
mechanism: Some(Mechanism::panic()),
thread_id: None,
stacktrace: Stacktrace::Raw { frames: captured },
};
scrub::scrub_exception(&mut exception, cfg.filter());
let debug_images = images::referenced_images(&modules, exception.stacktrace.frames());
send_blocking(cfg, exception, debug_images, location);
}
fn payload_message(info: &std::panic::PanicHookInfo<'_>) -> String {
let payload = info.payload();
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Box<dyn Any>".to_string()
}
}
fn should_send(cfg: &CrashConfig) -> bool {
should_send_with(
cfg.environment,
std::env::var_os("OPENLATCH_POSTHOG_HOST").is_some_and(|v| !v.is_empty()),
)
}
fn should_send_with(environment: &str, host_override: bool) -> bool {
environment != ENV_DEVELOPMENT || host_override
}
fn build_request_body(
cfg: &CrashConfig,
exception: &Exception,
images: &[DebugImage],
loc: Option<&Location<'_>>,
) -> serde_json::Value {
let mut properties = Map::new();
properties.insert("$exception_level".into(), Value::from("fatal"));
properties.insert(
"$exception_list".into(),
serde_json::to_value([exception]).unwrap_or(Value::Null),
);
properties.insert(
"$debug_images".into(),
serde_json::to_value(images).unwrap_or(Value::Null),
);
if let Some(loc) = loc {
properties.insert("$exception_panic_file".into(), Value::from(loc.file()));
properties.insert("$exception_panic_line".into(), Value::from(loc.line()));
properties.insert("$exception_panic_column".into(), Value::from(loc.column()));
}
properties.insert("environment".into(), Value::from(cfg.environment));
properties.insert("release".into(), Value::from(cfg.release));
if let Some(scope) = process_scope() {
for (k, v) in scope.iter() {
properties.insert(k.clone(), v.clone());
}
}
serde_json::json!({
"api_key": cfg.project_key,
"event": "$exception",
"distinct_id": cfg.distinct_id,
"properties": properties,
})
}
fn send_blocking(
cfg: &CrashConfig,
exception: Exception,
images: Vec<DebugImage>,
loc: Option<&Location<'_>>,
) {
if !should_send(cfg) {
tracing::debug!("crash report suppressed: development build, no host override");
return;
}
let Ok(client) = crate::egress::build_blocking_client_with(
crate::egress::Consumer::Telemetry,
&cfg.egress,
crate::egress::Timeouts::total(CRASH_POST_TIMEOUT),
) else {
return;
};
let body = build_request_body(cfg, &exception, &images, loc);
let url = format!("{}/capture/", cfg.host.trim_end_matches('/'));
let _ = client.post(url).json(&body).send();
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn config(environment: &'static str) -> CrashConfig {
CrashConfig::new(
crate::egress::EgressConfig::direct(),
"phc_test_public".to_string(),
"http://127.0.0.1:9".to_string(),
"agt_test".to_string(),
environment,
"deadbeef",
)
}
fn exception(message: &str) -> Exception {
Exception {
exception_type: "panic".into(),
value: message.to_string(),
mechanism: Some(Mechanism::panic()),
thread_id: None,
stacktrace: Stacktrace::Raw {
frames: vec![payload::Frame::address_only(
Some("0x1000".into()),
Some("0x1000".into()),
)],
},
}
}
#[test]
fn the_gate_admits_production_and_an_explicit_host_override_only() {
assert!(
!should_send_with(ENV_DEVELOPMENT, false),
"a development build with no override must send nothing"
);
assert!(
should_send_with(ENV_DEVELOPMENT, true),
"the override is what keeps the e2e suite's crash test reachable"
);
assert!(should_send_with("production", false));
assert!(should_send_with("production", true));
}
#[test]
fn a_key_being_present_does_not_open_the_development_gate() {
let cfg = config(ENV_DEVELOPMENT);
assert!(!cfg.project_key.is_empty());
assert!(!should_send_with(cfg.environment, false));
}
#[test]
fn the_gate_reads_openlatch_posthog_host() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::set_var("OPENLATCH_POSTHOG_HOST", "http://127.0.0.1:8123");
}
let with_override = should_send(&config(ENV_DEVELOPMENT));
unsafe {
std::env::remove_var("OPENLATCH_POSTHOG_HOST");
}
let without_override = should_send(&config(ENV_DEVELOPMENT));
assert!(with_override);
assert!(!without_override);
}
#[test]
fn api_key_and_distinct_id_round_trip_unscrubbed() {
let cfg = config("production");
let fake_secret = format!("{}{}", "sk_live_", "0123456789abcdefghijklmn"); let mut exc = exception(&format!("boom {fake_secret} for alice@example.com"));
scrub::scrub_exception(&mut exc, cfg.filter());
let body = build_request_body(&cfg, &exc, &[], None);
assert_eq!(body["api_key"], "phc_test_public");
assert_eq!(body["distinct_id"], "agt_test");
let rendered = body.to_string();
assert!(
!rendered.contains(&fake_secret),
"the panic message reached the wire unscrubbed"
);
}
#[test]
fn the_request_body_carries_the_level_release_and_environment() {
let cfg = config("production");
let body = build_request_body(&cfg, &exception("boom"), &[], None);
assert_eq!(body["event"], "$exception");
let props = &body["properties"];
assert_eq!(props["$exception_level"], "fatal");
assert_eq!(props["environment"], "production");
assert_eq!(props["release"], "deadbeef");
assert!(props["$exception_list"].is_array());
assert!(props["$debug_images"].is_array());
assert_eq!(props["$exception_list"][0]["type"], "panic");
assert_eq!(props["$exception_list"][0]["stacktrace"]["type"], "raw");
}
#[test]
fn the_panic_location_rides_its_own_properties() {
let cfg = config("production");
let loc = Location::caller();
let body = build_request_body(&cfg, &exception("boom"), &[], Some(loc));
let props = &body["properties"];
assert_eq!(props["$exception_panic_file"], loc.file());
assert_eq!(props["$exception_panic_line"], loc.line());
assert_eq!(props["$exception_panic_column"], loc.column());
}
#[test]
fn the_daemon_scope_replaces_the_cli_scope_main_set_first() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
set_cli_scope("daemon");
let after_cli = process_scope().expect("cli scope installed");
assert_eq!(after_cli["process_type"], "cli");
assert_eq!(after_cli["command"], "daemon");
set_daemon_scope(7443, 4242);
let after_daemon = process_scope().expect("daemon scope installed");
assert_eq!(after_daemon["process_type"], "daemon");
assert_eq!(after_daemon["port"], 7443);
assert_eq!(after_daemon["pid"], 4242);
assert!(
!after_daemon.contains_key("command"),
"the daemon scope replaces the cli one rather than merging into it"
);
let cfg = config("production");
let body = build_request_body(&cfg, &exception("boom"), &[], None);
let props = &body["properties"];
assert_eq!(props["process_type"], "daemon");
assert_eq!(props["port"], 7443);
assert_eq!(props["pid"], 4242);
}
#[test]
fn a_captured_payload_is_well_formed_end_to_end() {
let cfg = config("production");
let bt = backtrace::Backtrace::new();
let modules = images::collect_loaded_modules();
assert!(
!modules.is_empty(),
"a running process always has at least its own executable mapped"
);
let captured = frames::build_frames(&bt, &modules);
assert!(
captured.iter().any(|f| f.image_addr.is_some()),
"no frame matched any module: find_module or the module ranges are wrong"
);
let mut exc = Exception {
exception_type: "panic".into(),
value: "boom".into(),
mechanism: Some(Mechanism::panic()),
thread_id: None,
stacktrace: Stacktrace::Raw { frames: captured },
};
scrub::scrub_exception(&mut exc, cfg.filter());
let debug_images = images::referenced_images(&modules, exc.stacktrace.frames());
let body = build_request_body(&cfg, &exc, &debug_images, None);
let frames_json = &body["properties"]["$exception_list"][0]["stacktrace"]["frames"];
let frames_json = frames_json.as_array().expect("frames is an array");
assert!(!frames_json.is_empty());
for frame in frames_json {
let obj = frame.as_object().expect("frame is an object");
assert!(obj.contains_key("in_app"), "in_app missing: {frame}");
assert_eq!(obj["platform"], "native");
}
let images_json = body["properties"]["$debug_images"]
.as_array()
.expect("images is an array");
assert!(
!images_json.is_empty(),
"every referenced module came back with no debug id — nothing here can ever \
symbolicate, and an empty array is exactly what that looks like"
);
for image in images_json {
assert!(
image["debug_id"].as_str().is_some_and(|s| !s.is_empty()),
"an image with no debug id can match no symbol set: {image}"
);
assert!(
image["image_addr"].as_str().is_some_and(|s| !s.is_empty()),
"image_addr is required for the address match: {image}"
);
}
}
}