use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DeceptiveCanaryConfig {
pub enabled: bool,
pub traffic_percentage: f64,
pub team_identifiers: TeamIdentifiers,
#[serde(skip_serializing_if = "Option::is_none")]
pub opt_out_header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub opt_out_query_param: Option<String>,
pub deceptive_deploy_url: String,
pub routing_strategy: CanaryRoutingStrategy,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats: Option<CanaryStats>,
}
impl Default for DeceptiveCanaryConfig {
fn default() -> Self {
Self {
enabled: false,
traffic_percentage: 0.05, team_identifiers: TeamIdentifiers::default(),
opt_out_header: Some("X-Opt-Out-Canary".to_string()),
opt_out_query_param: Some("no-canary".to_string()),
deceptive_deploy_url: String::new(),
routing_strategy: CanaryRoutingStrategy::ConsistentHash,
stats: Some(CanaryStats::default()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TeamIdentifiers {
#[serde(default)]
pub user_agents: Option<Vec<String>>,
#[serde(default)]
pub ip_ranges: Option<Vec<String>>,
#[serde(default)]
pub headers: Option<HashMap<String, String>>,
#[serde(default)]
pub teams: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CanaryRoutingStrategy {
ConsistentHash,
Random,
RoundRobin,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CanaryStats {
pub total_requests: u64,
pub canary_requests: u64,
pub opted_out_requests: u64,
pub matched_requests: u64,
}
impl CanaryStats {
pub fn canary_percentage(&self) -> f64 {
if self.matched_requests == 0 {
return 0.0;
}
(self.canary_requests as f64 / self.matched_requests as f64) * 100.0
}
}
pub struct DeceptiveCanaryRouter {
config: DeceptiveCanaryConfig,
round_robin_counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
total_requests: std::sync::atomic::AtomicU64,
canary_requests: std::sync::atomic::AtomicU64,
opted_out_requests: std::sync::atomic::AtomicU64,
matched_requests: std::sync::atomic::AtomicU64,
}
impl DeceptiveCanaryRouter {
pub fn new(config: DeceptiveCanaryConfig) -> Self {
Self {
config,
round_robin_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
total_requests: std::sync::atomic::AtomicU64::new(0),
canary_requests: std::sync::atomic::AtomicU64::new(0),
opted_out_requests: std::sync::atomic::AtomicU64::new(0),
matched_requests: std::sync::atomic::AtomicU64::new(0),
}
}
pub fn should_route_to_canary(
&self,
user_agent: Option<&str>,
ip_address: Option<&str>,
headers: &HashMap<String, String>,
query_params: &HashMap<String, String>,
user_id: Option<&str>,
) -> bool {
if !self.config.enabled {
return false;
}
if let Some(opt_out_header) = &self.config.opt_out_header {
if headers.get(opt_out_header).is_some() {
return false;
}
}
if let Some(opt_out_param) = &self.config.opt_out_query_param {
if query_params.get(opt_out_param).is_some() {
return false;
}
}
if !self.matches_team_criteria(user_agent, ip_address, headers) {
return false;
}
match self.config.routing_strategy {
CanaryRoutingStrategy::ConsistentHash => {
self.consistent_hash_route(user_id, ip_address)
}
CanaryRoutingStrategy::Random => self.random_route(),
CanaryRoutingStrategy::RoundRobin => self.round_robin_route(),
}
}
fn matches_team_criteria(
&self,
user_agent: Option<&str>,
ip_address: Option<&str>,
headers: &HashMap<String, String>,
) -> bool {
if let Some(user_agents) = &self.config.team_identifiers.user_agents {
if let Some(ua) = user_agent {
let matches = user_agents.iter().any(|pattern| {
if pattern == "*" {
true
} else {
ua.contains(pattern)
}
});
if !matches {
return false;
}
} else if !user_agents.contains(&"*".to_string()) {
return false;
}
}
if let Some(ip_ranges) = &self.config.team_identifiers.ip_ranges {
if let Some(ip) = ip_address {
let matches = ip_ranges.iter().any(|range| {
if range == "*" {
true
} else {
ip.starts_with(range) || range == ip
}
});
if !matches {
return false;
}
} else if !ip_ranges.contains(&"*".to_string()) {
return false;
}
}
if let Some(header_rules) = &self.config.team_identifiers.headers {
for (header_name, expected_value) in header_rules {
if let Some(actual_value) = headers.get(header_name) {
if actual_value != expected_value && expected_value != "*" {
return false;
}
} else if expected_value != "*" {
return false;
}
}
}
true
}
fn consistent_hash_route(&self, user_id: Option<&str>, ip_address: Option<&str>) -> bool {
let hash_input = user_id.unwrap_or_else(|| ip_address.unwrap_or("default"));
let mut hasher = DefaultHasher::new();
hash_input.hash(&mut hasher);
let hash = hasher.finish();
let percentage = (hash % 10000) as f64 / 10000.0;
percentage < self.config.traffic_percentage
}
fn random_route(&self) -> bool {
use rand::Rng;
let mut rng = rand::thread_rng();
let random_value: f64 = rng.gen();
random_value < self.config.traffic_percentage
}
fn round_robin_route(&self) -> bool {
let counter = self.round_robin_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let cycle_size = (1.0 / self.config.traffic_percentage) as u64;
counter.is_multiple_of(cycle_size)
}
pub fn config(&self) -> &DeceptiveCanaryConfig {
&self.config
}
pub fn update_config(&mut self, config: DeceptiveCanaryConfig) {
self.config = config;
}
pub fn stats(&self) -> CanaryStats {
CanaryStats {
total_requests: self.total_requests.load(std::sync::atomic::Ordering::Relaxed),
canary_requests: self.canary_requests.load(std::sync::atomic::Ordering::Relaxed),
opted_out_requests: self.opted_out_requests.load(std::sync::atomic::Ordering::Relaxed),
matched_requests: self.matched_requests.load(std::sync::atomic::Ordering::Relaxed),
}
}
pub fn record_request(&self, routed: bool, opted_out: bool, matched: bool) {
self.total_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if routed {
self.canary_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
if opted_out {
self.opted_out_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
if matched {
self.matched_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
}
impl Default for DeceptiveCanaryRouter {
fn default() -> Self {
Self::new(DeceptiveCanaryConfig::default())
}
}