pub mod breadcrumbs;
pub mod build_id;
pub mod context;
pub mod digest;
#[cfg(feature = "native-crash")]
pub mod native;
pub mod panic;
pub mod reader;
pub mod redact;
pub mod report;
pub mod secure_fs;
#[cfg(feature = "shared-ring")]
pub mod shared_ring;
#[cfg(feature = "tracing")]
pub mod tracing_layer;
pub mod writer;
use std::path::PathBuf;
use std::sync::OnceLock;
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
use std::time::{SystemTime, UNIX_EPOCH};
pub use context::{Adhoc, DomainContext};
pub use redact::{BasicRedactor, NoopRedactor, Redactor};
pub use report::{Artifact, Env, EventKind, Frame, Meta, Report, SCHEMA_VERSION};
#[cfg(feature = "tracing")]
pub use tracing_layer::BreadcrumbLayer;
pub use writer::Retention;
pub use serde_json;
pub struct Config {
pub project: String,
pub version: String,
pub git_sha: Option<String>,
pub build_id: Option<String>,
pub reports_dir: PathBuf,
pub breadcrumb_capacity: usize,
pub features: Vec<String>,
pub retention: writer::Retention,
pub preserve_max_bytes: u64,
pub redactor: Box<dyn Redactor>,
#[cfg(feature = "shared-ring")]
pub shared_ring: Option<std::sync::Arc<shared_ring::SharedRing>>,
pub install_panic_hook: bool,
pub install_native_crash_handler: bool,
}
impl Config {
pub fn new(
project: impl Into<String>,
version: impl Into<String>,
reports_dir: impl Into<PathBuf>,
) -> Self {
Config {
project: project.into(),
version: version.into(),
git_sha: None,
build_id: build_id::read_self(),
reports_dir: reports_dir.into(),
breadcrumb_capacity: 128,
features: Vec::new(),
retention: writer::Retention::default(),
preserve_max_bytes: 256 * 1024 * 1024,
redactor: Box::new(NoopRedactor),
#[cfg(feature = "shared-ring")]
shared_ring: None,
install_panic_hook: true,
install_native_crash_handler: false,
}
}
#[must_use]
pub fn git_sha(mut self, sha: impl Into<String>) -> Self {
self.git_sha = Some(sha.into());
self
}
#[must_use]
pub fn breadcrumb_capacity(mut self, n: usize) -> Self {
self.breadcrumb_capacity = n;
self
}
#[must_use]
pub fn features<I, S>(mut self, features: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.features = features.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn retention(mut self, retention: writer::Retention) -> Self {
self.retention = retention;
self
}
#[must_use]
pub fn preserve_max_bytes(mut self, bytes: u64) -> Self {
self.preserve_max_bytes = bytes;
self
}
#[must_use]
pub fn redactor(mut self, redactor: Box<dyn Redactor>) -> Self {
self.redactor = redactor;
self
}
#[cfg(feature = "shared-ring")]
#[must_use]
pub fn shared_ring(mut self, ring: std::sync::Arc<shared_ring::SharedRing>) -> Self {
self.shared_ring = Some(ring);
self
}
#[must_use]
pub fn install_panic_hook(mut self, yes: bool) -> Self {
self.install_panic_hook = yes;
self
}
#[must_use]
pub fn install_native_crash_handler(mut self, yes: bool) -> Self {
self.install_native_crash_handler = yes;
self
}
}
static CONFIG: OnceLock<Config> = OnceLock::new();
pub fn init(config: Config) -> bool {
breadcrumbs::init(config.breadcrumb_capacity);
let install_hook = config.install_panic_hook;
if CONFIG.set(config).is_err() {
return false;
}
if install_hook {
panic::install_hook();
}
#[cfg(feature = "native-crash")]
if let Some(cfg) = CONFIG.get()
&& cfg.install_native_crash_handler
{
native::install(cfg);
}
true
}
#[allow(clippy::needless_doctest_main)]
#[must_use]
pub fn run_crash_monitor_if_env() -> bool {
#[cfg(feature = "native-crash")]
{
native::run_crash_monitor_if_env()
}
#[cfg(not(feature = "native-crash"))]
{
false
}
}
#[must_use]
pub fn config() -> Option<&'static Config> {
CONFIG.get()
}
#[must_use]
pub fn now_ms() -> u128 {
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
{
use std::sync::atomic::{AtomicU64, Ordering};
static TICK: AtomicU64 = AtomicU64::new(0);
u128::from(TICK.fetch_add(1, Ordering::Relaxed))
}
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
{
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
}
struct PendingArtifact {
kind: String,
src: PathBuf,
name: String,
note: Option<String>,
}
pub struct Capture {
kind: EventKind,
message: String,
error_chain: Vec<String>,
domain_kind: Option<String>,
domain_key: String,
domain: serde_json::Value,
backtrace: Vec<Frame>,
artifacts: Vec<PendingArtifact>,
}
impl Capture {
pub fn new(kind: EventKind, message: impl Into<String>) -> Self {
Capture {
kind,
message: message.into(),
error_chain: Vec::new(),
domain_kind: None,
domain_key: String::new(),
domain: serde_json::Value::Null,
backtrace: Vec::new(),
artifacts: Vec::new(),
}
}
#[must_use]
pub fn error_chain<I, S>(mut self, chain: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.error_chain = chain.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn domain(mut self, ctx: &dyn DomainContext) -> Self {
let captured = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(
ctx.domain_kind().to_owned(),
ctx.grouping_key(),
ctx.to_json(),
)
}));
if let Ok((kind, key, json)) = captured {
self.domain_kind = Some(kind);
self.domain_key = key;
self.domain = json;
}
self
}
#[must_use]
pub fn with_backtrace(mut self) -> Self {
let bt = std::backtrace::Backtrace::force_capture();
self.backtrace = panic::frames_from_backtrace(&bt);
self
}
#[must_use]
pub fn backtrace_frames(mut self, frames: Vec<Frame>) -> Self {
self.backtrace = frames;
self
}
#[must_use]
pub fn preserve(
mut self,
artifact_kind: impl Into<String>,
src: impl Into<PathBuf>,
name: impl Into<String>,
note: Option<String>,
) -> Self {
self.artifacts.push(PendingArtifact {
kind: artifact_kind.into(),
src: src.into(),
name: name.into(),
note,
});
self
}
#[must_use]
pub fn emit(self) -> Option<PathBuf> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || self.emit_inner()))
.unwrap_or(None)
}
fn emit_inner(self) -> Option<PathBuf> {
let cfg = CONFIG.get()?;
let redactor = cfg.redactor.as_ref();
let message = redactor.redact(&self.message);
let error_chain: Vec<String> = self
.error_chain
.iter()
.map(|s| redactor.redact(s))
.collect();
let mut domain = self.domain;
redactor.redact_json(&mut domain);
let breadcrumbs = redacted_breadcrumbs(cfg, redactor);
let backtrace: Vec<Frame> = self
.backtrace
.into_iter()
.map(|frame| Frame {
address: frame.address,
symbol: frame.symbol.as_deref().map(|s| redactor.redact(s)),
location: frame.location.as_deref().map(|l| redactor.redact(l)),
})
.collect();
let domain_facet = match &self.domain_kind {
Some(kind) => format!("{kind}|{}", self.domain_key),
None => self.domain_key.clone(),
};
let fingerprint = writer::fingerprint(&cfg.project, self.kind, &domain_facet, &message);
let now = now_ms();
let mut report = Report {
schema_version: SCHEMA_VERSION,
kind: self.kind,
message,
meta: Meta {
project: cfg.project.clone(),
version: cfg.version.clone(),
git_sha: cfg.git_sha.clone(),
build_id: cfg.build_id.clone(),
captured_at_ms: now,
pid: std::process::id(),
ppid: Meta::current_ppid(),
thread: Meta::current_thread_name().map(|t| redactor.redact(&t)),
},
error_chain,
backtrace,
breadcrumbs,
domain_kind: self.domain_kind,
domain,
env: Env::with_features(cfg.features.clone()),
artifacts: Vec::new(),
fingerprint,
occurrences: 1,
first_seen_ms: now,
last_seen_ms: now,
};
let dir = writer::ensure_group_dir(&cfg.reports_dir, &report.fingerprint).ok()?;
let staged: Vec<_> = self
.artifacts
.iter()
.map(|pending| {
(
pending,
writer::stage_artifact(
&dir,
&pending.src,
&pending.name,
cfg.preserve_max_bytes,
),
)
})
.collect();
let _lock = match writer::lock_group(&dir) {
Ok(lock) => lock,
Err(_) => {
for (_, staged) in staged {
if let Ok(staged) = staged {
staged.discard();
}
}
return None;
}
};
for (pending, staged) in staged {
report
.artifacts
.push(commit_pending(&dir, pending, staged, redactor));
}
writer::commit_into(&dir, &mut report).ok()?;
drop(_lock);
writer::prune(&cfg.reports_dir, cfg.retention, &dir);
Some(dir)
}
}
fn commit_pending(
dir: &std::path::Path,
pending: &PendingArtifact,
staged: std::io::Result<writer::Staged>,
redactor: &dyn Redactor,
) -> Artifact {
let note = |extra: Option<&str>| {
let text = match (&pending.note, extra) {
(Some(n), Some(extra)) => format!("{n} ({extra})"),
(Some(n), None) => n.clone(),
(None, Some(extra)) => extra.to_owned(),
(None, None) => return None,
};
Some(redactor.redact(&text))
};
match staged.and_then(|s| writer::commit_artifact(dir, s)) {
Ok(writer::Preserved::Copied { rel, bytes, digest }) => Artifact {
kind: pending.kind.clone(),
rel_path: rel,
note: note(None),
digest: Some(digest),
bytes: Some(bytes),
},
Ok(writer::Preserved::Reused { rel, bytes, digest }) => Artifact {
kind: pending.kind.clone(),
rel_path: rel,
note: note(Some("unchanged since an earlier occurrence of this bug")),
digest: Some(digest),
bytes: Some(bytes),
},
Ok(writer::Preserved::TooLarge { bytes, limit }) => Artifact {
kind: pending.kind.clone(),
rel_path: String::new(),
note: note(Some(&format!(
"NOT PRESERVED: {bytes} bytes exceeds the {limit}-byte cap; \
the source was left in place at {}",
pending.src.display()
))),
digest: None,
bytes: Some(bytes),
},
Err(e) => Artifact {
kind: pending.kind.clone(),
rel_path: String::new(),
note: note(Some(&format!("NOT PRESERVED: {e}"))),
digest: None,
bytes: None,
},
}
}
fn redacted_breadcrumbs(_cfg: &Config, redactor: &dyn Redactor) -> Vec<breadcrumbs::Breadcrumb> {
let mut crumbs = breadcrumbs::snapshot();
for c in &mut crumbs {
c.message = redactor.redact(&c.message);
redactor.redact_json(&mut c.fields);
}
#[cfg(feature = "shared-ring")]
if let Some(ring) = &_cfg.shared_ring {
let me = std::process::id();
crumbs.extend(
ring.snapshot()
.into_iter()
.filter(|c| c.pid != Some(me) && c.pid.is_some()),
);
crumbs.sort_by_key(|c| c.ts_ms);
}
crumbs
}
#[must_use]
pub fn error_chain_of(err: &(dyn std::error::Error + 'static)) -> Vec<String> {
let mut out = vec![err.to_string()];
let mut src = err.source();
while let Some(e) = src {
out.push(e.to_string());
src = e.source();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug)]
struct Inner;
impl std::fmt::Display for Inner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "inner cause")
}
}
impl std::error::Error for Inner {}
#[derive(Debug)]
struct Outer(Inner);
impl std::fmt::Display for Outer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "outer failure")
}
}
impl std::error::Error for Outer {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
#[test]
fn error_chain_of_walks_sources_outer_to_inner() {
let e = Outer(Inner);
let chain = error_chain_of(&e);
assert_eq!(chain, vec!["outer failure", "inner cause"]);
}
#[test]
fn now_ms_is_positive() {
assert!(now_ms() > 0);
}
#[test]
fn native_crash_handler_is_never_armed_by_default() {
let cfg = Config::new("t", "0.1.0", "/tmp/faultbox-test-reports");
assert!(
!cfg.install_native_crash_handler,
"enabling the `native-crash` feature must not arm the handler; \
the host must opt in *and* call run_crash_monitor_if_env()"
);
}
}