1use serde::{Deserialize, Serialize};
7use std::collections::hash_map::DefaultHasher;
8use std::collections::HashMap;
9use std::hash::{Hash, Hasher};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14pub struct DeceptiveCanaryConfig {
15 pub enabled: bool,
17 pub traffic_percentage: f64,
19 pub team_identifiers: TeamIdentifiers,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub opt_out_header: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub opt_out_query_param: Option<String>,
27 pub deceptive_deploy_url: String,
29 pub routing_strategy: CanaryRoutingStrategy,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub stats: Option<CanaryStats>,
34}
35
36impl Default for DeceptiveCanaryConfig {
37 fn default() -> Self {
38 Self {
39 enabled: false,
40 traffic_percentage: 0.05, team_identifiers: TeamIdentifiers::default(),
42 opt_out_header: Some("X-Opt-Out-Canary".to_string()),
43 opt_out_query_param: Some("no-canary".to_string()),
44 deceptive_deploy_url: String::new(),
45 routing_strategy: CanaryRoutingStrategy::ConsistentHash,
46 stats: Some(CanaryStats::default()),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54pub struct TeamIdentifiers {
55 #[serde(default)]
57 pub user_agents: Option<Vec<String>>,
58 #[serde(default)]
60 pub ip_ranges: Option<Vec<String>>,
61 #[serde(default)]
63 pub headers: Option<HashMap<String, String>>,
64 #[serde(default)]
66 pub teams: Option<Vec<String>>,
67}
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[serde(rename_all = "snake_case")]
73pub enum CanaryRoutingStrategy {
74 ConsistentHash,
76 Random,
78 RoundRobin,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, Default)]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85pub struct CanaryStats {
86 pub total_requests: u64,
88 pub canary_requests: u64,
90 pub opted_out_requests: u64,
92 pub matched_requests: u64,
94}
95
96impl CanaryStats {
97 pub fn canary_percentage(&self) -> f64 {
99 if self.matched_requests == 0 {
100 return 0.0;
101 }
102 (self.canary_requests as f64 / self.matched_requests as f64) * 100.0
103 }
104}
105
106pub struct DeceptiveCanaryRouter {
110 config: DeceptiveCanaryConfig,
111 round_robin_counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
112 total_requests: std::sync::atomic::AtomicU64,
114 canary_requests: std::sync::atomic::AtomicU64,
115 opted_out_requests: std::sync::atomic::AtomicU64,
116 matched_requests: std::sync::atomic::AtomicU64,
117}
118
119impl DeceptiveCanaryRouter {
120 pub fn new(config: DeceptiveCanaryConfig) -> Self {
122 Self {
123 config,
124 round_robin_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
125 total_requests: std::sync::atomic::AtomicU64::new(0),
126 canary_requests: std::sync::atomic::AtomicU64::new(0),
127 opted_out_requests: std::sync::atomic::AtomicU64::new(0),
128 matched_requests: std::sync::atomic::AtomicU64::new(0),
129 }
130 }
131
132 pub fn should_route_to_canary(
144 &self,
145 user_agent: Option<&str>,
146 ip_address: Option<&str>,
147 headers: &HashMap<String, String>,
148 query_params: &HashMap<String, String>,
149 user_id: Option<&str>,
150 ) -> bool {
151 if !self.config.enabled {
153 return false;
154 }
155
156 if let Some(opt_out_header) = &self.config.opt_out_header {
158 if headers.get(opt_out_header).is_some() {
159 return false;
160 }
161 }
162
163 if let Some(opt_out_param) = &self.config.opt_out_query_param {
164 if query_params.get(opt_out_param).is_some() {
165 return false;
166 }
167 }
168
169 if !self.matches_team_criteria(user_agent, ip_address, headers) {
171 return false;
172 }
173
174 match self.config.routing_strategy {
177 CanaryRoutingStrategy::ConsistentHash => {
178 self.consistent_hash_route(user_id, ip_address)
179 }
180 CanaryRoutingStrategy::Random => self.random_route(),
181 CanaryRoutingStrategy::RoundRobin => self.round_robin_route(),
182 }
183 }
184
185 fn matches_team_criteria(
187 &self,
188 user_agent: Option<&str>,
189 ip_address: Option<&str>,
190 headers: &HashMap<String, String>,
191 ) -> bool {
192 if let Some(user_agents) = &self.config.team_identifiers.user_agents {
194 if let Some(ua) = user_agent {
195 let matches = user_agents.iter().any(|pattern| {
196 if pattern == "*" {
197 true
198 } else {
199 ua.contains(pattern)
201 }
202 });
203 if !matches {
204 return false;
205 }
206 } else if !user_agents.contains(&"*".to_string()) {
207 return false;
208 }
209 }
210
211 if let Some(ip_ranges) = &self.config.team_identifiers.ip_ranges {
213 if let Some(ip) = ip_address {
214 let matches = ip_ranges.iter().any(|range| {
215 if range == "*" {
216 true
217 } else {
218 ip.starts_with(range) || range == ip
220 }
221 });
222 if !matches {
223 return false;
224 }
225 } else if !ip_ranges.contains(&"*".to_string()) {
226 return false;
227 }
228 }
229
230 if let Some(header_rules) = &self.config.team_identifiers.headers {
232 for (header_name, expected_value) in header_rules {
233 if let Some(actual_value) = headers.get(header_name) {
234 if actual_value != expected_value && expected_value != "*" {
235 return false;
236 }
237 } else if expected_value != "*" {
238 return false;
239 }
240 }
241 }
242
243 true
244 }
245
246 fn consistent_hash_route(&self, user_id: Option<&str>, ip_address: Option<&str>) -> bool {
248 let hash_input = user_id.unwrap_or_else(|| ip_address.unwrap_or("default"));
250
251 let mut hasher = DefaultHasher::new();
253 hash_input.hash(&mut hasher);
254 let hash = hasher.finish();
255
256 let percentage = (hash % 10000) as f64 / 10000.0;
258
259 percentage < self.config.traffic_percentage
260 }
261
262 fn random_route(&self) -> bool {
264 use rand::Rng;
265 let mut rng = rand::thread_rng();
266 let random_value: f64 = rng.gen();
267 random_value < self.config.traffic_percentage
268 }
269
270 fn round_robin_route(&self) -> bool {
272 let counter = self.round_robin_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
273 let cycle_size = (1.0 / self.config.traffic_percentage) as u64;
274 counter.is_multiple_of(cycle_size)
275 }
276
277 pub fn config(&self) -> &DeceptiveCanaryConfig {
279 &self.config
280 }
281
282 pub fn update_config(&mut self, config: DeceptiveCanaryConfig) {
284 self.config = config;
285 }
286
287 pub fn stats(&self) -> CanaryStats {
289 CanaryStats {
290 total_requests: self.total_requests.load(std::sync::atomic::Ordering::Relaxed),
291 canary_requests: self.canary_requests.load(std::sync::atomic::Ordering::Relaxed),
292 opted_out_requests: self.opted_out_requests.load(std::sync::atomic::Ordering::Relaxed),
293 matched_requests: self.matched_requests.load(std::sync::atomic::Ordering::Relaxed),
294 }
295 }
296
297 pub fn record_request(&self, routed: bool, opted_out: bool, matched: bool) {
299 self.total_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
300 if routed {
301 self.canary_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
302 }
303 if opted_out {
304 self.opted_out_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
305 }
306 if matched {
307 self.matched_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
308 }
309 }
310}
311
312impl Default for DeceptiveCanaryRouter {
313 fn default() -> Self {
314 Self::new(DeceptiveCanaryConfig::default())
315 }
316}