1use indexmap::IndexMap;
2use vector_lib::{
3 config::clone_input_definitions, configurable::configurable_component, transform::SyncTransform,
4};
5
6use crate::{
7 conditions::{AnyCondition, Condition, ConditionConfig, VrlConfig},
8 config::{
9 DataType, GenerateConfig, Input, OutputId, TransformConfig, TransformContext,
10 TransformOutput,
11 },
12 event::Event,
13 schema,
14 transforms::Transform,
15};
16
17pub(crate) const UNMATCHED_ROUTE: &str = "_unmatched";
18
19#[derive(Clone)]
20pub struct Route {
21 conditions: Vec<(String, Condition)>,
22 reroute_unmatched: bool,
23}
24
25impl Route {
26 pub fn new(config: &RouteConfig, context: &TransformContext) -> crate::Result<Self> {
27 let mut conditions = Vec::with_capacity(config.route.len());
28 for (output_name, condition) in config.route.iter() {
29 let condition =
30 condition.build(&context.enrichment_tables, &context.metrics_storage)?;
31 conditions.push((output_name.clone(), condition));
32 }
33 Ok(Self {
34 conditions,
35 reroute_unmatched: config.reroute_unmatched,
36 })
37 }
38}
39
40impl SyncTransform for Route {
41 fn transform(&mut self, event: Event, output: &mut vector_lib::transform::TransformOutputsBuf) {
42 let mut check_failed: usize = 0;
43 for (output_name, condition) in &self.conditions {
44 let (result, event) = condition.check(event.clone());
45 if result {
46 output.push(Some(output_name), event);
47 } else {
48 check_failed += 1;
49 }
50 }
51 if self.reroute_unmatched && check_failed == self.conditions.len() {
52 output.push(Some(UNMATCHED_ROUTE), event);
53 }
54 }
55}
56
57#[configurable_component(transform(
59 "route",
60 "Split a stream of events into multiple sub-streams based on user-supplied conditions."
61))]
62#[derive(Clone, Debug)]
63#[serde(deny_unknown_fields)]
64pub struct RouteConfig {
65 #[serde(default = "crate::serde::default_true")]
74 #[configurable(metadata(docs::human_name = "Reroute Unmatched Events"))]
75 reroute_unmatched: bool,
76
77 #[configurable(metadata(docs::additional_props_description = "An individual route."))]
89 #[configurable(metadata(docs::examples = "route_examples()"))]
90 route: IndexMap<String, AnyCondition>,
91}
92
93fn route_examples() -> IndexMap<String, AnyCondition> {
94 IndexMap::from([
95 (
96 "foo-exists".to_owned(),
97 AnyCondition::Map(ConditionConfig::Vrl(VrlConfig {
98 source: "exists(.foo)".to_owned(),
99 ..Default::default()
100 })),
101 ),
102 (
103 "foo-does-not-exist".to_owned(),
104 AnyCondition::Map(ConditionConfig::Vrl(VrlConfig {
105 source: "!exists(.foo)".to_owned(),
106 ..Default::default()
107 })),
108 ),
109 ])
110}
111
112impl GenerateConfig for RouteConfig {
113 fn generate_config() -> toml::Value {
114 toml::Value::try_from(Self {
115 reroute_unmatched: true,
116 route: route_examples(),
117 })
118 .unwrap()
119 }
120}
121
122#[async_trait::async_trait]
123#[typetag::serde(name = "route")]
124impl TransformConfig for RouteConfig {
125 async fn build(&self, context: &TransformContext) -> crate::Result<Transform> {
126 let route = Route::new(self, context)?;
127 Ok(Transform::synchronous(route))
128 }
129
130 fn input(&self) -> Input {
131 Input::all()
132 }
133
134 fn validate(&self, _: &TransformContext) -> Result<(), Vec<String>> {
135 if self.route.contains_key(UNMATCHED_ROUTE) {
136 Err(vec![format!(
137 "cannot have a named output with reserved name: `{UNMATCHED_ROUTE}`"
138 )])
139 } else {
140 Ok(())
141 }
142 }
143
144 fn validate_env(&self, context: &TransformContext) -> Result<(), Vec<String>> {
145 let errors: Vec<String> = self
146 .route
147 .iter()
148 .filter_map(|(name, condition)| {
149 condition
150 .validate(&context.enrichment_tables, &context.metrics_storage)
151 .err()
152 .map(|e| format!("route \"{name}\": {e}"))
153 })
154 .collect();
155
156 if errors.is_empty() {
157 Ok(())
158 } else {
159 Err(errors)
160 }
161 }
162
163 fn outputs(
164 &self,
165 _: &TransformContext,
166 input_definitions: &[(OutputId, schema::Definition)],
167 ) -> Vec<TransformOutput> {
168 let mut result: Vec<TransformOutput> = self
169 .route
170 .keys()
171 .map(|output_name| {
172 TransformOutput::new(
173 DataType::all_bits(),
174 clone_input_definitions(input_definitions),
175 )
176 .with_port(output_name)
177 })
178 .collect();
179 if self.reroute_unmatched {
180 result.push(
181 TransformOutput::new(
182 DataType::all_bits(),
183 clone_input_definitions(input_definitions),
184 )
185 .with_port(UNMATCHED_ROUTE),
186 );
187 }
188 result
189 }
190
191 fn enable_concurrency(&self) -> bool {
192 true
193 }
194}
195
196#[cfg(test)]
197mod test {
198 use std::collections::HashMap;
199
200 use indoc::indoc;
201 use vector_lib::{config::LogNamespace, transform::TransformOutputsBuf};
202
203 use super::*;
204 use crate::{
205 config::{ConfigBuilder, build_unit_tests},
206 test_util::components::{COMPONENT_MULTIPLE_OUTPUTS_TESTS, init_test},
207 };
208
209 #[test]
210 fn generate_config() {
211 crate::test_util::test_generate_config::<super::RouteConfig>();
212 }
213
214 #[test]
215 fn can_serialize_remap() {
216 let config = serde_yaml::from_str::<RouteConfig>(indoc! {"
219 route:
220 first:
221 type: vrl
222 source: '.message == \"hello world\"'
223 "})
224 .unwrap();
225
226 assert_eq!(
227 serde_json::to_string(&config).unwrap(),
228 r#"{"reroute_unmatched":true,"route":{"first":{"type":"vrl","source":".message == \"hello world\""}}}"#
229 );
230 }
231
232 #[test]
233 fn route_pass_all_route_conditions() {
234 let output_names = vec!["first", "second", "third", UNMATCHED_ROUTE];
235 let event = Event::from_json_value(
236 serde_json::json!({"message": "hello world", "second": "second", "third": "third"}),
237 LogNamespace::Legacy,
238 )
239 .unwrap();
240 let config = serde_yaml::from_str::<RouteConfig>(indoc! {"
241 route:
242 first:
243 type: vrl
244 source: '.message == \"hello world\"'
245 second:
246 type: vrl
247 source: '.second == \"second\"'
248 third:
249 type: vrl
250 source: '.third == \"third\"'
251 "})
252 .unwrap();
253
254 let mut transform = Route::new(&config, &Default::default()).unwrap();
255 let mut outputs = TransformOutputsBuf::new_with_capacity(
256 output_names
257 .iter()
258 .map(|output_name| {
259 TransformOutput::new(DataType::all_bits(), HashMap::new())
260 .with_port(output_name.to_owned())
261 })
262 .collect(),
263 1,
264 );
265
266 transform.transform(event.clone(), &mut outputs);
267 for output_name in output_names {
268 let mut events: Vec<_> = outputs.drain_named(output_name).collect();
269 if output_name == UNMATCHED_ROUTE {
270 assert!(events.is_empty());
271 } else {
272 assert_eq!(events.len(), 1);
273 assert_eq!(events.pop().unwrap(), event);
274 }
275 }
276 }
277
278 #[test]
279 fn route_pass_one_route_condition() {
280 let output_names = vec!["first", "second", "third", UNMATCHED_ROUTE];
281 let event = Event::from_json_value(
282 serde_json::json!({"message": "hello world"}),
283 LogNamespace::Legacy,
284 )
285 .unwrap();
286 let config = serde_yaml::from_str::<RouteConfig>(indoc! {"
287 route:
288 first:
289 type: vrl
290 source: '.message == \"hello world\"'
291 second:
292 type: vrl
293 source: '.second == \"second\"'
294 third:
295 type: vrl
296 source: '.third == \"third\"'
297 "})
298 .unwrap();
299
300 let mut transform = Route::new(&config, &Default::default()).unwrap();
301 let mut outputs = TransformOutputsBuf::new_with_capacity(
302 output_names
303 .iter()
304 .map(|output_name| {
305 TransformOutput::new(DataType::all_bits(), HashMap::new())
306 .with_port(output_name.to_owned())
307 })
308 .collect(),
309 1,
310 );
311
312 transform.transform(event.clone(), &mut outputs);
313 for output_name in output_names {
314 let mut events: Vec<_> = outputs.drain_named(output_name).collect();
315 if output_name == "first" {
316 assert_eq!(events.len(), 1);
317 assert_eq!(events.pop().unwrap(), event);
318 }
319 assert_eq!(events.len(), 0);
320 }
321 }
322
323 #[test]
324 fn route_pass_no_route_condition() {
325 let output_names = vec!["first", "second", "third", UNMATCHED_ROUTE];
326 let event =
327 Event::from_json_value(serde_json::json!({"message": "NOPE"}), LogNamespace::Legacy)
328 .unwrap();
329 let config = serde_yaml::from_str::<RouteConfig>(indoc! {"
330 route:
331 first:
332 type: vrl
333 source: '.message == \"hello world\"'
334 second:
335 type: vrl
336 source: '.second == \"second\"'
337 third:
338 type: vrl
339 source: '.third == \"third\"'
340 "})
341 .unwrap();
342
343 let mut transform = Route::new(&config, &Default::default()).unwrap();
344 let mut outputs = TransformOutputsBuf::new_with_capacity(
345 output_names
346 .iter()
347 .map(|output_name| {
348 TransformOutput::new(DataType::all_bits(), HashMap::new())
349 .with_port(output_name.to_owned())
350 })
351 .collect(),
352 1,
353 );
354
355 transform.transform(event.clone(), &mut outputs);
356 for output_name in output_names {
357 let mut events: Vec<_> = outputs.drain_named(output_name).collect();
358 if output_name == UNMATCHED_ROUTE {
359 assert_eq!(events.len(), 1);
360 assert_eq!(events.pop().unwrap(), event);
361 }
362 assert_eq!(events.len(), 0);
363 }
364 }
365
366 #[test]
367 fn route_no_unmatched_output() {
368 let output_names = vec!["first", "second", "third", UNMATCHED_ROUTE];
369 let event =
370 Event::from_json_value(serde_json::json!({"message": "NOPE"}), LogNamespace::Legacy)
371 .unwrap();
372 let config = serde_yaml::from_str::<RouteConfig>(indoc! {"
373 reroute_unmatched: false
374 route:
375 first:
376 type: vrl
377 source: '.message == \"hello world\"'
378 second:
379 type: vrl
380 source: '.second == \"second\"'
381 third:
382 type: vrl
383 source: '.third == \"third\"'
384 "})
385 .unwrap();
386
387 let mut transform = Route::new(&config, &Default::default()).unwrap();
388 let mut outputs = TransformOutputsBuf::new_with_capacity(
389 output_names
390 .iter()
391 .map(|output_name| {
392 TransformOutput::new(DataType::all_bits(), HashMap::new())
393 .with_port(output_name.to_owned())
394 })
395 .collect(),
396 1,
397 );
398
399 transform.transform(event.clone(), &mut outputs);
400 for output_name in output_names {
401 let events: Vec<_> = outputs.drain_named(output_name).collect();
402 assert_eq!(events.len(), 0);
403 }
404 }
405
406 #[tokio::test]
407 async fn route_metrics_with_output_tag() {
408 init_test();
409
410 let config: ConfigBuilder = serde_yaml::from_str(indoc! {"
411 transforms:
412 foo:
413 inputs: []
414 type: route
415 route:
416 first:
417 type: is_log
418 tests:
419 - name: metric output
420 input:
421 insert_at: foo
422 value: none
423 outputs:
424 - extract_from: foo.first
425 conditions:
426 - type: vrl
427 source: \"true\"
428 "})
429 .unwrap();
430
431 let mut tests = build_unit_tests(config).await.unwrap();
432 assert!(tests.remove(0).run().await.errors.is_empty());
433 COMPONENT_MULTIPLE_OUTPUTS_TESTS.assert(&["output"]);
435 }
436}