use super::data::InsightData;
use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::Arc;
pub type InsightCallback = Arc<dyn Fn(&InsightData) + Send + Sync>;
#[derive(Clone)]
pub struct InsightConfig {
pub(crate) sample_rate: f64,
pub(crate) max_body_size: usize,
pub(crate) skip_paths: HashSet<String>,
pub(crate) skip_path_prefixes: HashSet<String>,
pub(crate) header_whitelist: HashSet<String>,
pub(crate) response_header_whitelist: HashSet<String>,
pub(crate) capture_request_body: bool,
pub(crate) capture_response_body: bool,
pub(crate) on_insight: Option<InsightCallback>,
pub(crate) dashboard_path: Option<String>,
pub(crate) stats_path: Option<String>,
pub(crate) store_capacity: usize,
pub(crate) sensitive_headers: HashSet<String>,
pub(crate) capturable_content_types: HashSet<String>,
}
impl Default for InsightConfig {
fn default() -> Self {
Self::new()
}
}
impl InsightConfig {
pub fn new() -> Self {
let mut sensitive = HashSet::new();
sensitive.insert("authorization".to_string());
sensitive.insert("cookie".to_string());
sensitive.insert("x-api-key".to_string());
sensitive.insert("x-auth-token".to_string());
let mut capturable = HashSet::new();
capturable.insert("application/json".to_string());
capturable.insert("text/plain".to_string());
capturable.insert("text/html".to_string());
capturable.insert("application/xml".to_string());
capturable.insert("text/xml".to_string());
Self {
sample_rate: 1.0,
max_body_size: 4096,
skip_paths: HashSet::new(),
skip_path_prefixes: HashSet::new(),
header_whitelist: HashSet::new(),
response_header_whitelist: HashSet::new(),
capture_request_body: false,
capture_response_body: false,
on_insight: None,
dashboard_path: Some("/insights".to_string()),
stats_path: Some("/insights/stats".to_string()),
store_capacity: 1000,
sensitive_headers: sensitive,
capturable_content_types: capturable,
}
}
pub fn sample_rate(mut self, rate: f64) -> Self {
self.sample_rate = rate.clamp(0.0, 1.0);
self
}
pub fn max_body_size(mut self, size: usize) -> Self {
self.max_body_size = size;
self
}
pub fn skip_path(mut self, path: impl Into<String>) -> Self {
self.skip_paths.insert(path.into());
self
}
pub fn skip_paths(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
for path in paths {
self.skip_paths.insert(path.into());
}
self
}
pub fn skip_path_prefix(mut self, prefix: impl Into<String>) -> Self {
self.skip_path_prefixes.insert(prefix.into());
self
}
pub fn header_whitelist(
mut self,
headers: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.header_whitelist = headers
.into_iter()
.map(|h| h.into().to_lowercase())
.collect();
self
}
pub fn response_header_whitelist(
mut self,
headers: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.response_header_whitelist = headers
.into_iter()
.map(|h| h.into().to_lowercase())
.collect();
self
}
pub fn capture_request_body(mut self, capture: bool) -> Self {
self.capture_request_body = capture;
self
}
pub fn capture_response_body(mut self, capture: bool) -> Self {
self.capture_response_body = capture;
self
}
pub fn on_insight<F>(mut self, callback: F) -> Self
where
F: Fn(&InsightData) + Send + Sync + 'static,
{
self.on_insight = Some(Arc::new(callback));
self
}
pub fn dashboard_path(mut self, path: Option<impl Into<String>>) -> Self {
self.dashboard_path = path.map(|p| p.into());
self
}
pub fn stats_path(mut self, path: Option<impl Into<String>>) -> Self {
self.stats_path = path.map(|p| p.into());
self
}
pub fn store_capacity(mut self, capacity: usize) -> Self {
self.store_capacity = capacity;
self
}
pub fn sensitive_header(mut self, header: impl Into<String>) -> Self {
self.sensitive_headers.insert(header.into().to_lowercase());
self
}
pub fn capturable_content_types(
mut self,
types: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.capturable_content_types =
types.into_iter().map(|t| t.into().to_lowercase()).collect();
self
}
pub(crate) fn should_skip_path(&self, path: &str) -> bool {
if self.skip_paths.contains(path) {
return true;
}
for prefix in &self.skip_path_prefixes {
if path.starts_with(prefix) {
return true;
}
}
if let Some(ref dashboard) = self.dashboard_path {
if path == dashboard {
return true;
}
}
if let Some(ref stats) = self.stats_path {
if path == stats {
return true;
}
}
false
}
pub(crate) fn should_sample(&self) -> bool {
if self.sample_rate >= 1.0 {
return true;
}
if self.sample_rate <= 0.0 {
return false;
}
rand_sample(self.sample_rate)
}
pub(crate) fn should_capture_header(&self, name: &str) -> bool {
if self.header_whitelist.is_empty() {
return false;
}
if self.header_whitelist.contains("*") {
return true;
}
self.header_whitelist
.contains(Self::to_lowercase_cow(name).as_ref())
}
pub(crate) fn should_capture_response_header(&self, name: &str) -> bool {
if self.response_header_whitelist.is_empty() {
return false;
}
if self.response_header_whitelist.contains("*") {
return true;
}
self.response_header_whitelist
.contains(Self::to_lowercase_cow(name).as_ref())
}
pub(crate) fn is_sensitive_header(&self, name: &str) -> bool {
self.sensitive_headers
.contains(Self::to_lowercase_cow(name).as_ref())
}
fn to_lowercase_cow(s: &str) -> Cow<'_, str> {
if s.chars().any(|c| c.is_uppercase()) {
Cow::Owned(s.to_lowercase())
} else {
Cow::Borrowed(s)
}
}
pub(crate) fn is_capturable_content_type(&self, content_type: &str) -> bool {
let ct_lower = content_type.to_lowercase();
for allowed in &self.capturable_content_types {
if ct_lower.starts_with(allowed)
|| (allowed.ends_with("/*") && ct_lower.starts_with(&allowed[..allowed.len() - 1]))
{
return true;
}
}
ct_lower.starts_with("text/") || ct_lower.starts_with("application/json")
}
}
fn rand_sample(rate: f64) -> bool {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let threshold = (rate * u32::MAX as f64) as u32;
nanos < threshold
}
impl std::fmt::Debug for InsightConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InsightConfig")
.field("sample_rate", &self.sample_rate)
.field("max_body_size", &self.max_body_size)
.field("skip_paths", &self.skip_paths)
.field("skip_path_prefixes", &self.skip_path_prefixes)
.field("header_whitelist", &self.header_whitelist)
.field("capture_request_body", &self.capture_request_body)
.field("capture_response_body", &self.capture_response_body)
.field("dashboard_path", &self.dashboard_path)
.field("stats_path", &self.stats_path)
.field("store_capacity", &self.store_capacity)
.field("on_insight", &self.on_insight.is_some())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = InsightConfig::new();
assert_eq!(config.sample_rate, 1.0);
assert_eq!(config.max_body_size, 4096);
assert!(!config.capture_request_body);
assert!(!config.capture_response_body);
assert_eq!(config.dashboard_path, Some("/insights".to_string()));
assert_eq!(config.stats_path, Some("/insights/stats".to_string()));
}
#[test]
fn test_sample_rate_clamping() {
let config = InsightConfig::new().sample_rate(1.5);
assert_eq!(config.sample_rate, 1.0);
let config = InsightConfig::new().sample_rate(-0.5);
assert_eq!(config.sample_rate, 0.0);
}
#[test]
fn test_skip_paths() {
let config = InsightConfig::new()
.skip_path("/health")
.skip_path("/metrics")
.skip_path_prefix("/internal/");
assert!(config.should_skip_path("/health"));
assert!(config.should_skip_path("/metrics"));
assert!(config.should_skip_path("/internal/debug"));
assert!(!config.should_skip_path("/users"));
}
#[test]
fn test_header_whitelist() {
let config = InsightConfig::new().header_whitelist(vec!["Content-Type", "User-Agent"]);
assert!(config.should_capture_header("content-type"));
assert!(config.should_capture_header("Content-Type"));
assert!(config.should_capture_header("user-agent"));
assert!(!config.should_capture_header("authorization"));
}
#[test]
fn test_header_wildcard() {
let config = InsightConfig::new().header_whitelist(vec!["*"]);
assert!(config.should_capture_header("any-header"));
assert!(config.should_capture_header("another-one"));
}
#[test]
fn test_sensitive_headers() {
let config = InsightConfig::new();
assert!(config.is_sensitive_header("authorization"));
assert!(config.is_sensitive_header("Authorization"));
assert!(config.is_sensitive_header("cookie"));
assert!(!config.is_sensitive_header("content-type"));
}
#[test]
fn test_capturable_content_types() {
let config = InsightConfig::new();
assert!(config.is_capturable_content_type("application/json"));
assert!(config.is_capturable_content_type("application/json; charset=utf-8"));
assert!(config.is_capturable_content_type("text/plain"));
assert!(config.is_capturable_content_type("text/html"));
}
#[test]
fn test_dashboard_path_exclusion() {
let config = InsightConfig::new()
.dashboard_path(Some("/insights"))
.stats_path(Some("/insights/stats"));
assert!(config.should_skip_path("/insights"));
assert!(config.should_skip_path("/insights/stats"));
assert!(!config.should_skip_path("/users"));
}
}