pub mod breadcrumbs;
pub mod build_id;
pub mod context;
#[cfg(feature = "native-crash")]
pub mod native;
pub mod panic;
pub mod reader;
pub mod report;
#[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, BasicRedactor, DomainContext, 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")]
{
let cfg = CONFIG.get().expect("config was just set");
if 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 {
self.domain_kind = Some(ctx.domain_kind().to_owned());
self.domain_key = ctx.grouping_key();
self.domain = ctx.to_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> {
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 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(),
},
error_chain,
backtrace: self.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 = writer::lock_group(&dir).ok()?;
for (pending, staged) in staged {
report.artifacts.push(commit_pending(&dir, pending, staged));
}
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>,
) -> Artifact {
let note_with = |extra: &str| {
Some(match &pending.note {
Some(n) => format!("{n} ({extra})"),
None => extra.to_owned(),
})
};
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: pending.note.clone(),
digest: Some(digest),
bytes: Some(bytes),
},
Ok(writer::Preserved::Reused { rel, bytes, digest }) => Artifact {
kind: pending.kind.clone(),
rel_path: rel,
note: note_with("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_with(&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_with(&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()"
);
}
}