Skip to main content

vector/transforms/sample/
config.rs

1use snafu::Snafu;
2use vector_lib::{
3    config::LegacyKey,
4    configurable::configurable_component,
5    lookup::{lookup_v2::OptionalValuePath, owned_value_path},
6};
7use vrl::value::Kind;
8
9use super::transform::{DynamicSampleFields, Sample, SampleMode};
10use crate::{
11    conditions::AnyCondition,
12    config::{
13        DataType, GenerateConfig, Input, OutputId, TransformConfig, TransformContext,
14        TransformOutput,
15    },
16    schema,
17    template::UnconfinedTemplate,
18    transforms::Transform,
19};
20
21#[derive(Debug, Snafu)]
22pub enum SampleError {
23    // Errors from `determine_sample_mode`
24    #[snafu(display(
25        "Only positive, non-zero numbers are allowed values for `ratio`, value: {ratio}"
26    ))]
27    InvalidRatio { ratio: f64 },
28
29    #[snafu(display("Only non-zero numbers are allowed values for `rate`"))]
30    InvalidRate,
31
32    #[snafu(display("Only one value can be provided for either 'rate' or 'ratio', but not both"))]
33    InvalidStaticConfiguration,
34
35    #[snafu(display(
36        "Only one value can be provided for either 'ratio_field' or 'rate_field', but not both"
37    ))]
38    InvalidDynamicConfiguration,
39
40    #[snafu(display(
41        "Exactly one value must be provided for either 'rate' or 'ratio' to configure static sampling"
42    ))]
43    MissingStaticConfiguration,
44
45    #[snafu(display(
46        "'key_field' cannot be combined with 'ratio_field' or 'rate_field' because dynamic values can vary per event and break key-based coherence"
47    ))]
48    InvalidKeyFieldDynamicCombination,
49}
50
51/// Configuration for the `sample` transform.
52#[configurable_component(transform(
53    "sample",
54    "Sample events from an event stream based on supplied criteria and at a configurable rate."
55))]
56#[derive(Clone, Debug)]
57#[serde(deny_unknown_fields)]
58pub struct SampleConfig {
59    /// The rate at which events are forwarded, expressed as `1/N`.
60    ///
61    /// For example, `rate = 1500` means 1 out of every 1500 events are forwarded and the rest are
62    /// dropped. This differs from `ratio` which allows more precise control over the number of events
63    /// retained and values greater than 1/2.
64    #[configurable(required_one_of = "sampling_strategy")]
65    pub rate: Option<u64>,
66
67    /// The rate at which events are forwarded, expressed as a percentage.
68    ///
69    /// For example, `ratio = .13` means that 13% out of all events on the stream are forwarded and
70    /// the rest are dropped. This differs from `rate` allowing the configuration of a higher
71    /// precision value and also the ability to retain values of greater than 50% of all events.
72    #[configurable(required_one_of = "sampling_strategy", metadata(docs::examples = 0.13))]
73    #[configurable(validation(range(min = 0.0, max = 1.0)))]
74    pub ratio: Option<f64>,
75
76    /// The event field whose numeric value is used as the sampling ratio on a per-event basis.
77    ///
78    /// Accepts integer, floating point, or string values that parse as a number. The value must be
79    /// in `(0, 1]` to be considered valid (for example, `0.25` keeps 25%). If the field is missing
80    /// or invalid, static sampling settings (`rate` or `ratio`) are used as a fallback.
81    /// This option cannot be used together with `rate_field`.
82    pub ratio_field: Option<String>,
83
84    /// The event field whose integer value is used as the sampling rate on a per-event basis, expressed as `1/N`.
85    ///
86    /// Accepts an integer, or a string that parses as a positive integer; floating point values
87    /// are rejected. The value must be a positive integer to be considered valid. If the field is
88    /// missing or invalid, static sampling settings (`rate` or `ratio`) are used as a fallback.
89    /// This option cannot be used together with `ratio_field`.
90    pub rate_field: Option<String>,
91
92    /// The name of the field whose value is hashed to determine if the event should be
93    /// sampled.
94    ///
95    /// Each unique value for the key creates a bucket of related events to be sampled together
96    /// and the rate is applied to the buckets themselves to sample `1/N` buckets.  The overall rate
97    /// of sampling may differ from the configured one if values in the field are not uniformly
98    /// distributed. If left unspecified, or if the event doesn’t have `key_field`, then the
99    /// event is sampled independently.
100    ///
101    /// This can be useful to, for example, ensure that all logs for a given transaction are
102    /// sampled together, but that overall `1/N` transactions are sampled.
103    ///
104    /// This option cannot be combined with `ratio_field` or `rate_field`.
105    #[configurable(metadata(docs::examples = "message"))]
106    pub key_field: Option<String>,
107
108    /// The event key in which the sample rate is stored. If set to an empty string, the sample rate will not be added to the event.
109    #[configurable(metadata(docs::examples = "sample_rate"))]
110    #[serde(default = "default_sample_rate_key")]
111    pub sample_rate_key: OptionalValuePath,
112
113    /// The value to group events into separate buckets to be sampled independently.
114    ///
115    /// If left unspecified, or if the event doesn't have `group_by`, then the event is not
116    /// sampled separately.
117    ///
118    /// This can also be used with `ratio_field` or `rate_field` to apply dynamic sampling
119    /// independently per rendered group value.
120    #[configurable(metadata(
121        docs::examples = "{{ service }}",
122        docs::examples = "{{ hostname }}-{{ service }}"
123    ))]
124    pub group_by: Option<UnconfinedTemplate>,
125
126    /// A logical condition used to exclude events from sampling.
127    pub exclude: Option<AnyCondition>,
128}
129
130impl SampleConfig {
131    fn sample_rate(&self) -> Result<SampleMode, SampleError> {
132        if self.ratio_field.is_some() && self.rate_field.is_some() {
133            return Err(SampleError::InvalidDynamicConfiguration);
134        }
135
136        if self.key_field.is_some() && (self.ratio_field.is_some() || self.rate_field.is_some()) {
137            return Err(SampleError::InvalidKeyFieldDynamicCombination);
138        }
139
140        if self.rate.is_some() && self.ratio.is_some() {
141            return Err(SampleError::InvalidStaticConfiguration);
142        }
143
144        match (self.rate, self.ratio) {
145            (None, Some(ratio)) => {
146                if ratio <= 0.0 {
147                    Err(SampleError::InvalidRatio { ratio })
148                } else {
149                    Ok(SampleMode::new_ratio(ratio))
150                }
151            }
152            (Some(rate), None) => {
153                if rate == 0 {
154                    Err(SampleError::InvalidRate)
155                } else {
156                    Ok(SampleMode::new_rate(rate))
157                }
158            }
159            (None, None) => Err(SampleError::MissingStaticConfiguration),
160            _ => Err(SampleError::InvalidStaticConfiguration),
161        }
162    }
163}
164
165impl GenerateConfig for SampleConfig {
166    fn generate_config() -> serde_json::Value {
167        serde_json::to_value(Self {
168            rate: None,
169            ratio: Some(0.1),
170            ratio_field: None,
171            rate_field: None,
172            key_field: None,
173            group_by: None,
174            exclude: None::<AnyCondition>,
175            sample_rate_key: default_sample_rate_key(),
176        })
177        .unwrap()
178    }
179}
180
181#[async_trait::async_trait]
182#[typetag::serde(name = "sample")]
183impl TransformConfig for SampleConfig {
184    async fn build(&self, context: &TransformContext) -> crate::Result<Transform> {
185        let sample_mode = self.sample_rate()?;
186        let exclude = self
187            .exclude
188            .as_ref()
189            .map(|condition| condition.build(&context.enrichment_tables, &context.metrics_storage))
190            .transpose()?;
191
192        let sample = if self.ratio_field.is_some() || self.rate_field.is_some() {
193            Sample::new_with_dynamic(
194                Self::NAME.to_string(),
195                sample_mode,
196                DynamicSampleFields {
197                    ratio_field: self.ratio_field.clone(),
198                    rate_field: self.rate_field.clone(),
199                },
200                self.group_by.clone(),
201                exclude,
202                self.sample_rate_key.clone(),
203            )
204        } else {
205            Sample::new(
206                Self::NAME.to_string(),
207                sample_mode,
208                self.key_field.clone(),
209                self.group_by.clone(),
210                exclude,
211                self.sample_rate_key.clone(),
212            )
213        };
214
215        Ok(Transform::function(sample))
216    }
217
218    fn input(&self) -> Input {
219        Input::new(DataType::Log | DataType::Trace)
220    }
221
222    fn validate_structure(&self) -> Result<(), Vec<String>> {
223        self.sample_rate()
224            .map(|_| ())
225            .map_err(|e| vec![e.to_string()])
226    }
227
228    fn validate_with_context(&self, context: &TransformContext) -> Result<(), Vec<String>> {
229        if let Some(Err(e)) = self
230            .exclude
231            .as_ref()
232            .map(|c| c.validate(&context.enrichment_tables, &context.metrics_storage))
233        {
234            Err(vec![format!("exclude: {e}")])
235        } else {
236            Ok(())
237        }
238    }
239
240    fn outputs(
241        &self,
242        _: &TransformContext,
243        input_definitions: &[(OutputId, schema::Definition)],
244    ) -> Vec<TransformOutput> {
245        vec![TransformOutput::new(
246            DataType::Log | DataType::Trace,
247            input_definitions
248                .iter()
249                .map(|(output, definition)| {
250                    (
251                        output.clone(),
252                        definition.clone().with_source_metadata(
253                            SampleConfig::NAME,
254                            Some(LegacyKey::Overwrite(owned_value_path!("sample_rate"))),
255                            &owned_value_path!("sample_rate"),
256                            Kind::bytes(),
257                            None,
258                        ),
259                    )
260                })
261                .collect(),
262        )]
263    }
264}
265
266pub fn default_sample_rate_key() -> OptionalValuePath {
267    OptionalValuePath::from(owned_value_path!("sample_rate"))
268}
269
270#[cfg(test)]
271mod tests {
272    use crate::{
273        config::TransformConfig,
274        transforms::sample::config::{SampleConfig, SampleError},
275    };
276
277    #[test]
278    fn generate_config() {
279        crate::test_util::test_generate_config::<SampleConfig>();
280    }
281
282    #[test]
283    fn rejects_dynamic_ratio_only_configuration() {
284        let config = SampleConfig {
285            rate: None,
286            ratio: None,
287            ratio_field: Some("sample_rate".to_string()),
288            rate_field: None,
289            key_field: None,
290            sample_rate_key: super::default_sample_rate_key(),
291            group_by: None,
292            exclude: None,
293        };
294
295        let err = config.sample_rate().unwrap_err();
296        assert!(matches!(err, SampleError::MissingStaticConfiguration));
297    }
298
299    #[test]
300    fn rejects_dynamic_rate_only_configuration() {
301        let config = SampleConfig {
302            rate: None,
303            ratio: None,
304            ratio_field: None,
305            rate_field: Some("sample_rate_n".to_string()),
306            key_field: None,
307            sample_rate_key: super::default_sample_rate_key(),
308            group_by: None,
309            exclude: None,
310        };
311
312        let err = config.sample_rate().unwrap_err();
313        assert!(matches!(err, SampleError::MissingStaticConfiguration));
314    }
315
316    #[test]
317    fn validates_static_with_dynamic_configuration() {
318        let config = SampleConfig {
319            rate: Some(10),
320            ratio: None,
321            ratio_field: None,
322            rate_field: Some("sample_rate_n".to_string()),
323            key_field: None,
324            sample_rate_key: super::default_sample_rate_key(),
325            group_by: None,
326            exclude: None,
327        };
328
329        assert!(config.validate_structure().is_ok());
330    }
331
332    #[test]
333    fn rejects_both_dynamic_fields_configuration() {
334        let config = SampleConfig {
335            rate: Some(10),
336            ratio: None,
337            ratio_field: Some("sample_rate".to_string()),
338            rate_field: Some("sample_rate_n".to_string()),
339            key_field: None,
340            sample_rate_key: super::default_sample_rate_key(),
341            group_by: None,
342            exclude: None,
343        };
344
345        let err = config.sample_rate().unwrap_err();
346        assert!(matches!(err, SampleError::InvalidDynamicConfiguration));
347    }
348
349    #[test]
350    fn rejects_key_field_with_dynamic_configuration() {
351        let config = SampleConfig {
352            rate: Some(10),
353            ratio: None,
354            ratio_field: Some("sample_ratio".to_string()),
355            rate_field: None,
356            key_field: Some("trace_id".to_string()),
357            sample_rate_key: super::default_sample_rate_key(),
358            group_by: None,
359            exclude: None,
360        };
361
362        let err = config.sample_rate().unwrap_err();
363        assert!(matches!(
364            err,
365            SampleError::InvalidKeyFieldDynamicCombination
366        ));
367    }
368}