saluki_components/transforms/mrf_gateway/
mod.rs1use std::collections::HashSet;
4
5use agent_data_plane_config::{domains::multi_region_failover::MetricMirroring, Live};
6use async_trait::async_trait;
7use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
8use saluki_core::{
9 components::{
10 transforms::{Transform, TransformBuilder, TransformContext},
11 BuildContext,
12 },
13 data_model::event::{Event, EventType},
14 topology::{EventsBuffer, OutputDefinition},
15};
16use saluki_error::GenericError;
17use tokio::select;
18use tracing::{debug, error};
19
20pub struct MrfMetricsGatewayConfiguration {
29 enabled: bool,
30 metric_mirroring: Live<MetricMirroring>,
31}
32
33impl MrfMetricsGatewayConfiguration {
34 pub fn new(enabled: bool, metric_mirroring: Live<MetricMirroring>) -> Self {
45 Self {
46 enabled,
47 metric_mirroring,
48 }
49 }
50}
51
52#[derive(Debug)]
54enum GatewayMode {
55 Inactive,
57 ForwardAll,
59 FilteredForward { allowlist: HashSet<String> },
61}
62
63struct Routing {
69 enabled: bool,
70 metric_mirroring: MetricMirroring,
71 mode: GatewayMode,
72}
73
74impl Routing {
75 fn new(enabled: bool, metric_mirroring: MetricMirroring) -> Self {
76 let mut routing = Self {
77 enabled,
78 metric_mirroring,
79 mode: GatewayMode::Inactive,
80 };
81 routing.rebuild_mode();
82
83 routing
84 }
85
86 fn set_metric_mirroring(&mut self, metric_mirroring: MetricMirroring) {
87 self.metric_mirroring = metric_mirroring;
88 self.rebuild_mode();
89 debug!(mode = ?self.mode, "MRF metrics gateway routing state rebuilt.");
90 }
91
92 fn rebuild_mode(&mut self) {
93 self.mode = if !(self.enabled && self.metric_mirroring.enabled) {
94 GatewayMode::Inactive
95 } else if self.metric_mirroring.allowlist.is_empty() {
96 GatewayMode::ForwardAll
97 } else {
98 GatewayMode::FilteredForward {
99 allowlist: self.metric_mirroring.allowlist.iter().cloned().collect(),
100 }
101 };
102 }
103
104 fn should_forward(&self, event: &Event) -> bool {
105 match &self.mode {
106 GatewayMode::Inactive => false,
107 GatewayMode::ForwardAll => true,
108 GatewayMode::FilteredForward { allowlist } => {
109 let Event::Metric(metric) = event else {
110 return false;
111 };
112 allowlist.contains(metric.context().name().as_ref())
113 }
114 }
115 }
116
117 async fn process_event_batch(
118 &self, mut events: EventsBuffer, context: &mut TransformContext,
119 ) -> Result<(), GenericError> {
120 let input_count = events.len();
121 events.remove_if(|event| !self.should_forward(event));
122 let forwarded_count = events.len();
123 let dropped_count = input_count.saturating_sub(forwarded_count);
124
125 let sent_count = context.dispatcher().buffered()?.send_all(events).await?;
126 debug!(
127 forwarded_events = sent_count,
128 dropped_events = dropped_count,
129 "MRF metrics gateway processed event batch."
130 );
131
132 Ok(())
133 }
134}
135
136pub struct MrfMetricsGateway {
141 enabled: bool,
142 metric_mirroring: Live<MetricMirroring>,
143}
144
145impl MrfMetricsGateway {
146 fn new(config: &MrfMetricsGatewayConfiguration) -> Self {
147 Self {
148 enabled: config.enabled,
149 metric_mirroring: config.metric_mirroring.clone(),
150 }
151 }
152}
153
154#[async_trait]
155impl TransformBuilder for MrfMetricsGatewayConfiguration {
156 async fn build(&self, _context: BuildContext) -> Result<Box<dyn Transform + Send>, GenericError> {
157 Ok(Box::new(MrfMetricsGateway::new(self)))
158 }
159
160 fn input_event_type(&self) -> EventType {
161 EventType::Metric
162 }
163
164 fn outputs(&self) -> &[OutputDefinition<EventType>] {
165 static OUTPUTS: &[OutputDefinition<EventType>] = &[OutputDefinition::default_output(EventType::Metric)];
166 OUTPUTS
167 }
168}
169
170impl MemoryBounds for MrfMetricsGatewayConfiguration {
171 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
172 let allowlist = &self.metric_mirroring.allowlist;
173 builder
174 .minimum()
175 .with_single_value::<MrfMetricsGateway>("component struct")
176 .with_fixed_amount("hashset overhead", std::mem::size_of::<HashSet<String>>())
177 .with_fixed_amount(
178 "allowlist strings",
180 allowlist
181 .iter()
182 .map(|name| name.len() + std::mem::size_of::<String>())
183 .sum::<usize>()
184 * 3,
185 )
186 .with_fixed_amount(
187 "hashset buckets",
188 allowlist.len() * std::mem::size_of::<Option<String>>() * 2,
189 );
190 }
191}
192
193#[async_trait]
194impl Transform for MrfMetricsGateway {
195 async fn run(self: Box<Self>, mut context: TransformContext) -> Result<(), GenericError> {
196 let mut health = context.take_health_handle();
197 let Self {
200 enabled,
201 mut metric_mirroring,
202 } = *self;
203 let mut routing = Routing::new(enabled, (*metric_mirroring).clone());
204
205 health.mark_ready();
206 debug!(mode = ?routing.mode, "MRF metrics gateway transform started.");
207
208 loop {
209 select! {
210 _ = health.live() => continue,
211 maybe_events = context.events().next() => match maybe_events {
212 Some(events) => {
213 if let Err(e) = routing.process_event_batch(events, &mut context).await {
214 error!(error = %e, "MRF metrics gateway failed to process event batch.");
215 }
216 }
217 None => {
218 debug!("Event stream terminated, shutting down MRF metrics gateway transform.");
219 break;
220 }
221 },
222 new_metric_mirroring = metric_mirroring.changed() => {
223 routing.set_metric_mirroring(new_metric_mirroring);
224 },
225 }
226 }
227
228 debug!("MRF metrics gateway transform stopped.");
229 Ok(())
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use std::{mem::size_of, sync::Arc};
236
237 use agent_data_plane_config::SalukiConfiguration;
238 use arc_swap::ArcSwap;
239 use saluki_core::{
240 accounting::ComponentRegistry,
241 data_model::event::{metric::Metric, Event},
242 support::SubsystemIdentifier,
243 };
244 use tokio::sync::watch;
245
246 use super::*;
247
248 struct LiveSource {
253 cell: Arc<ArcSwap<SalukiConfiguration>>,
254 tick: watch::Sender<()>,
255 }
256
257 impl LiveSource {
258 fn new(failover_metrics: bool, metric_allowlist: &[&str]) -> Self {
259 let (tick, _) = watch::channel(());
260
261 Self {
262 cell: Arc::new(ArcSwap::from_pointee(configuration(failover_metrics, metric_allowlist))),
263 tick,
264 }
265 }
266
267 fn metric_mirroring(&self) -> Live<MetricMirroring> {
268 Live::new_dynamic(Arc::clone(&self.cell), self.tick.subscribe(), |config| {
269 &config.domains.multi_region_failover.metric_mirroring
270 })
271 }
272
273 fn publish(&self, failover_metrics: bool, metric_allowlist: &[&str]) {
274 self.cell
275 .store(Arc::new(configuration(failover_metrics, metric_allowlist)));
276 self.tick.send(()).expect("a view still holds the receiver");
277 }
278 }
279
280 fn configuration(failover_metrics: bool, metric_allowlist: &[&str]) -> SalukiConfiguration {
281 let mut config = SalukiConfiguration::default();
282 let mirroring = &mut config.domains.multi_region_failover.metric_mirroring;
283 mirroring.enabled = failover_metrics;
284 mirroring.allowlist = metric_allowlist.iter().map(|name| (*name).to_string()).collect();
285
286 config
287 }
288
289 fn routing(enabled: bool, source: &LiveSource) -> Routing {
291 let config = MrfMetricsGatewayConfiguration::new(enabled, source.metric_mirroring());
292 let gateway = MrfMetricsGateway::new(&config);
293
294 Routing::new(gateway.enabled, (*gateway.metric_mirroring).clone())
295 }
296
297 fn counter(name: &'static str) -> Event {
298 Event::Metric(Metric::counter(name, 1.0))
299 }
300
301 async fn await_update<T>(view: &mut Live<T>) -> T
303 where
304 T: Clone + PartialEq + 'static,
305 {
306 tokio::time::timeout(std::time::Duration::from_secs(2), view.changed())
307 .await
308 .expect("the published update should reach the view")
309 }
310
311 #[test]
312 fn memory_bounds_include_all_allowlist_copies() {
313 let allowlist = ["allowed.metric", "also.allowed"];
314 let source = LiveSource::new(true, &allowlist);
315 let config = MrfMetricsGatewayConfiguration::new(true, source.metric_mirroring());
316
317 let registry = ComponentRegistry::default();
318 config.specify_bounds(&mut registry.bounds_builder(&SubsystemIdentifier::from_dotted("test")));
319 let bounds = registry.as_bounds();
320
321 let allowlist_strings = allowlist
322 .iter()
323 .map(|name| name.len() + size_of::<String>())
324 .sum::<usize>();
325 let expected = size_of::<MrfMetricsGateway>()
326 + size_of::<HashSet<String>>()
327 + allowlist_strings * 3
328 + allowlist.len() * size_of::<Option<String>>() * 2;
329
330 assert_eq!(bounds.total_minimum_required_bytes(), expected);
331 assert_eq!(bounds.total_firm_limit_bytes(), expected);
332 }
333
334 #[tokio::test]
335 async fn failover_that_is_off_drops_everything() {
336 let source = LiveSource::new(true, &[]);
337 let routing = routing(false, &source);
338
339 assert!(!routing.should_forward(&counter("any.metric")));
340 }
341
342 #[tokio::test]
343 async fn mirroring_that_is_off_drops_everything() {
344 let source = LiveSource::new(false, &[]);
345 let routing = routing(true, &source);
346
347 assert!(!routing.should_forward(&counter("any.metric")));
348 }
349
350 #[tokio::test]
351 async fn an_empty_allowlist_forwards_everything() {
352 let source = LiveSource::new(true, &[]);
353 let routing = routing(true, &source);
354
355 assert!(routing.should_forward(&counter("any.metric")));
356 }
357
358 #[tokio::test]
359 async fn an_allowlist_forwards_only_matching_metrics() {
360 let source = LiveSource::new(true, &["allowed.metric"]);
361 let routing = routing(true, &source);
362
363 assert!(routing.should_forward(&counter("allowed.metric")));
364 assert!(!routing.should_forward(&counter("blocked.metric")));
365 }
366
367 #[tokio::test]
368 async fn a_mirroring_update_toggles_forwarding() {
369 let source = LiveSource::new(false, &[]);
370 let mut routing = routing(true, &source);
371 let mut view = source.metric_mirroring();
372
373 assert!(!routing.should_forward(&counter("any.metric")));
374
375 source.publish(true, &[]);
376 routing.set_metric_mirroring(await_update(&mut view).await);
377 assert!(routing.should_forward(&counter("any.metric")));
378
379 source.publish(false, &[]);
380 routing.set_metric_mirroring(await_update(&mut view).await);
381 assert!(!routing.should_forward(&counter("any.metric")));
382 }
383
384 #[tokio::test]
385 async fn an_allowlist_update_changes_filtering() {
386 let source = LiveSource::new(true, &[]);
387 let mut routing = routing(true, &source);
388 let mut view = source.metric_mirroring();
389
390 assert!(routing.should_forward(&counter("allowed.metric")));
391 assert!(routing.should_forward(&counter("also.allowed")));
392
393 source.publish(true, &["also.allowed"]);
394 routing.set_metric_mirroring(await_update(&mut view).await);
395
396 assert!(!routing.should_forward(&counter("allowed.metric")));
397 assert!(routing.should_forward(&counter("also.allowed")));
398 assert!(!routing.should_forward(&counter("blocked.metric")));
399 }
400}