ev_lib 0.17.0

EV-invest shared Rust libraries, one per feature
Documentation
//! Native (non-wasm) Sentry integration for backend services (Axum) — built on
//! the `sentry` crate. Mirrors the site's backend wiring: an [`init`] guard, a
//! [`tracing_layer`] for the subscriber, the tower [`NewSentryLayer`]/
//! [`SentryHttpLayer`] for HTTP capture, and a [`report`] helper for 5xx errors.

use std::error::Error as StdError;

use sentry::IntoDsn;
/// Re-exported tower layers (require the consumer's `ServiceBuilder`). Apply in
/// this order on the Axum router:
///
/// ```ignore
/// use tower::ServiceBuilder;
/// use ev_lib::error_monitoring::{NewSentryLayer, SentryHttpLayer};
/// let svc = ServiceBuilder::new()
///     .layer(NewSentryLayer::<axum::extract::Request>::new_from_top())
///     .layer(SentryHttpLayer::new().enable_transaction());
/// ```
pub use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer};
/// The `tracing` integration layer: add it to your `tracing_subscriber` registry
/// so error/warn events become Sentry breadcrumbs and events (mirrors the site's
/// `init_tracing`).
pub use sentry::integrations::tracing::layer as tracing_layer;
/// Builds `"<name>@<version>"` from the crate the macro is *invoked in* — call it
/// in your application crate and pass the result through [`Config::release`]
/// (e.g. `release: release_name!().map(|r| r.into_owned())`). Invoked anywhere
/// else it names that crate instead, which is why [`init`] cannot do this for you.
pub use sentry::release_name;

/// Backend Sentry configuration. Read `dsn`/`environment` from the environment in
/// your app; `dsn` `None` disables Sentry (a silent no-op).
#[derive(Clone, Debug)]
pub struct Config {
	/// The Sentry DSN, or `None` to disable reporting. An empty or malformed DSN
	/// disables it too, rather than failing the process.
	pub dsn: Option<String>,
	/// Deployment environment tag (e.g. `"production"`, `"staging"`).
	pub environment: String,
	/// Transaction trace sampling rate; see [`Config::traces_sample_rate_for`].
	pub traces_sample_rate: f32,
	/// Release the app's events are attributed to (e.g. `"site-backend@2.3.1"`,
	/// from [`release_name!`] in the app crate or your CI). `None` defers to the
	/// SDK's `SENTRY_RELEASE` env detection — the same default as the TS port —
	/// and events stay unattributed when that is unset too.
	pub release: Option<String>,
	/// Name of the service the events came from, reported as a `service` tag
	/// (e.g. `"piggybank-core"`). A Sentry project is a DSN, so sibling services
	/// commonly share one; without this they are separable only by hostname. Use
	/// the same value as `OTEL_SERVICE_NAME` so an issue, a trace and a log line
	/// agree on the name. `None` leaves events untagged.
	pub service: Option<String>,
}

impl Config {
	/// The site's sampling policy: 10% in production, 100% elsewhere.
	pub fn traces_sample_rate_for(environment: &str) -> f32 {
		if environment == "production" { 0.1 } else { 1.0 }
	}
}

/// Initializes Sentry, returning the guard that must be held for the lifetime of
/// the process (bind it in `main`). Returns `None` when the DSN is absent, empty
/// or malformed, so the caller's binding is simply inert — a monitoring
/// misconfiguration never takes the process down. Mirrors the site's
/// `sentry::init` block, and the wasm/TS ports' no-op on an unusable DSN.
pub fn init(config: &Config) -> Option<sentry::ClientInitGuard> {
	// Parse before handing the DSN over: `sentry::init((dsn, options))` would
	// `expect` on it and panic at boot.
	let dsn = config.dsn.as_deref().into_dsn().ok().flatten()?;
	let guard = sentry::init(sentry::ClientOptions {
		dsn: Some(dsn),
		release: config.release.clone().map(Into::into),
		environment: Some(config.environment.clone().into()),
		traces_sample_rate: config.traces_sample_rate,
		..Default::default()
	});
	if let Some(service) = &config.service {
		// The main hub, because every other thread lazily clones its own hub from
		// this scope — so tagging here reaches threads that do not exist yet, which
		// is why `init` belongs at the top of `main`.
		sentry::Hub::main().configure_scope(|scope| tag_service(scope, service));
	}
	Some(guard)
}

/// Reports an unexpected error to Sentry. Call only for genuinely unexpected
/// failures (5xx territory), mirroring the site's `error_reporter::report`.
pub fn report(error: &dyn StdError) {
	sentry::capture_error(error);
}

/// Names the service on everything the scope touches. A scope rather than
/// `before_send` because `before_send` fires for events only — transactions
/// apply the scope instead, and would otherwise go out unnamed.
fn tag_service(scope: &mut sentry::Scope, service: &str) {
	scope.set_tag("service", service);
}

#[cfg(test)]
mod tests {
	use std::{
		sync::{Arc, Mutex},
		time::Duration,
	};

	use super::*;

	#[test]
	fn sample_rate_policy() {
		assert_eq!(Config::traces_sample_rate_for("production"), 0.1);
		assert_eq!(Config::traces_sample_rate_for("development"), 1.0);
		assert_eq!(Config::traces_sample_rate_for("staging"), 1.0);
		assert_eq!(Config::traces_sample_rate_for(""), 1.0);
	}

	#[test]
	fn init_is_noop_without_dsn() {
		let config = Config {
			dsn: None,
			environment: "test".to_string(),
			traces_sample_rate: 1.0,
			release: None,
			service: None,
		};
		assert!(init(&config).is_none());
	}

	#[test]
	fn init_is_noop_with_an_unusable_dsn() {
		// A monitoring misconfiguration must degrade to disabled reporting, not
		// panic the process at boot (mirrors the wasm and TS ports).
		for dsn in ["https://sentry.io/42", "not a dsn", "ftp://public@example.com/1", "", "  "] {
			let config = Config {
				dsn: Some(dsn.to_string()),
				environment: "test".to_string(),
				traces_sample_rate: 1.0,
				release: None,
				service: None,
			};
			assert!(init(&config).is_none(), "an unusable DSN should disable reporting, got a guard for {dsn:?}");
		}
	}

	#[test]
	fn init_returns_a_guard_with_a_valid_dsn() {
		let config = Config {
			dsn: Some("https://abc@example.com/1".to_string()),
			environment: "test".to_string(),
			traces_sample_rate: 1.0,
			release: None,
			service: None,
		};
		let guard = init(&config);
		assert!(guard.is_some(), "a syntactically valid DSN should yield a guard");
		// Drop the guard explicitly; it must not block on a network flush
		// (default transport with an unreachable host would, hence the short
		// shutdown — but dropping the guard here is enough for the assertion).
		drop(guard);
	}

	// The guard derefs to the client, so these assert on the options this client
	// resolved — no global-hub reads that could race other tests.
	#[test]
	fn init_uses_the_configured_release() {
		let config = Config {
			dsn: Some("https://abc@example.com/1".to_string()),
			environment: "test".to_string(),
			traces_sample_rate: 1.0,
			release: Some("site-backend@2.3.1".to_string()),
			service: None,
		};
		let guard = init(&config).expect("valid DSN yields a guard");
		assert_eq!(guard.options().release.as_deref(), Some("site-backend@2.3.1"));
		drop(guard);
	}

	#[test]
	fn init_never_pins_its_own_release() {
		let config = Config {
			dsn: Some("https://abc@example.com/1".to_string()),
			environment: "test".to_string(),
			traces_sample_rate: 1.0,
			release: None,
			service: None,
		};
		let guard = init(&config).expect("valid DSN yields a guard");
		// With `release: None` the SDK still env-detects SENTRY_RELEASE, so pin
		// the actual invariant: events are never attributed to this library.
		let own_release = concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION"));
		assert_ne!(guard.options().release.as_deref(), Some(own_release), "the wrapper must not pin its own release");
		if std::env::var("SENTRY_RELEASE").is_err() {
			assert_eq!(guard.options().release, None, "no release without Config.release or SENTRY_RELEASE");
		}
		drop(guard);
	}

	// Scope-level, so these assert on a scope of their own — reading the main
	// hub's would race every other test in the binary.
	#[test]
	fn the_service_names_an_event() {
		let mut scope = sentry::Scope::default();
		tag_service(&mut scope, "piggybank-core");

		let event = scope.apply_to_event(sentry::protocol::Event::default()).expect("tagging must not drop the event");
		assert_eq!(event.tags.get("service").map(String::as_str), Some("piggybank-core"));
	}

	#[test]
	fn the_service_names_a_transaction_too() {
		// The reason this is a scope tag and not `before_send`: traces share the
		// issue list's need to say which service they came from, and `before_send`
		// never sees them.
		let mut scope = sentry::Scope::default();
		tag_service(&mut scope, "cabinet-backend");

		let mut transaction = sentry::protocol::Transaction::default();
		scope.apply_to_transaction(&mut transaction);
		assert_eq!(transaction.tags.get("service").map(String::as_str), Some("cabinet-backend"));
	}

	#[test]
	fn no_service_leaves_events_untagged() {
		let scope = sentry::Scope::default();

		let event = scope.apply_to_event(sentry::protocol::Event::default()).expect("an empty scope must not drop the event");
		assert!(!event.tags.contains_key("service"), "an unnamed service should add no tag, got {:?}", event.tags);
	}

	// In-memory transport: captures envelopes instead of POSTing them. This
	// replicates `sentry::test::TestTransport` without enabling the `test`
	// feature (which we are not allowed to add), so `report` is covered
	// deterministically with no network.
	struct CapturingTransport {
		envelopes: Arc<Mutex<Vec<sentry::Envelope>>>,
	}

	impl sentry::Transport for CapturingTransport {
		fn send_envelope(&self, envelope: sentry::Envelope) {
			self.envelopes.lock().unwrap().push(envelope);
		}
	}

	#[test]
	fn report_captures_the_error_as_an_event() {
		let captured: Arc<Mutex<Vec<sentry::Envelope>>> = Arc::new(Mutex::new(Vec::new()));
		let sink = captured.clone();

		let options = sentry::ClientOptions {
			dsn: Some("https://public@example.com/1".parse().unwrap()),
			transport: Some(Arc::new(move |_: &sentry::ClientOptions| {
				Arc::new(CapturingTransport { envelopes: sink.clone() }) as Arc<dyn sentry::Transport>
			})),
			..Default::default()
		};

		let hub = Arc::new(sentry::Hub::new(Some(Arc::new(options.into())), Arc::new(Default::default())));
		sentry::Hub::run(hub.clone(), || {
			let err = std::io::Error::other("disk on fire");
			report(&err);
		});
		hub.client().unwrap().flush(Some(Duration::from_secs(1)));

		let envelopes = captured.lock().unwrap();
		assert_eq!(envelopes.len(), 1, "report should send exactly one envelope");
		let event = envelopes[0].event().expect("the captured envelope should carry an event");
		let exception = event.exception.values.first().expect("capture_error records an exception value");
		assert!(
			exception.value.as_deref().unwrap_or_default().contains("disk on fire"),
			"the reported error message should reach Sentry, got {:?}",
			exception.value
		);
	}
}