relay_cogs/
cogs.rs

1use crate::time::Instant;
2use std::collections::BTreeMap;
3use std::fmt;
4use std::num::NonZeroUsize;
5use std::sync::Arc;
6
7use crate::{AppFeature, Measurements, ResourceId};
8use crate::{CogsMeasurement, CogsRecorder, Value};
9
10/// COGS measurements collector.
11///
12/// The collector is cheap to clone.
13#[derive(Clone)]
14pub struct Cogs {
15    recorder: Arc<dyn CogsRecorder>,
16}
17
18impl Cogs {
19    /// Creates a new [`Cogs`] from a [`recorder`](CogsRecorder).
20    pub fn new<T>(recorder: T) -> Self
21    where
22        T: CogsRecorder + 'static,
23    {
24        Self {
25            recorder: Arc::new(recorder),
26        }
27    }
28
29    /// Shortcut for creating a [`Cogs`] from a [`crate::NoopRecorder`].
30    ///
31    /// All collected measurements will be dropped.
32    pub fn noop() -> Self {
33        Self::new(crate::NoopRecorder)
34    }
35}
36
37impl Cogs {
38    /// Starts a recording for a COGS measurement.
39    ///
40    /// When the returned token is dropped the measurement will be recorded
41    /// with the configured [recorder](CogsRecorder).
42    ///
43    /// The recorded measurement can be attributed to multiple features by supplying a
44    /// weighted [`FeatureWeights`]. A single [`AppFeature`] attributes the entire measurement
45    /// to the feature.
46    ///
47    /// # Example:
48    ///
49    /// ```
50    /// # use relay_cogs::{AppFeature, Cogs, ResourceId};
51    /// # struct Span;
52    /// # fn scrub_sql(_: &mut Span) {}
53    /// # fn extract_tags(_: &mut Span) {};
54    ///
55    /// fn process_span(cogs: &Cogs, span: &mut Span) {
56    ///     let _token = cogs.timed(ResourceId::Relay, AppFeature::Spans);
57    ///
58    ///     scrub_sql(span);
59    ///     extract_tags(span);
60    /// }
61    ///
62    /// ```
63    pub fn timed<F: Into<FeatureWeights>>(&self, resource: ResourceId, weights: F) -> Token {
64        Token {
65            resource,
66            features: weights.into(),
67            measurements: Measurements::start(),
68            recorder: Some(Arc::clone(&self.recorder)),
69        }
70    }
71}
72
73/// An in progress COGS measurement.
74///
75/// The measurement is recorded when the token is dropped.
76#[must_use]
77pub struct Token {
78    resource: ResourceId,
79    features: FeatureWeights,
80    measurements: Measurements,
81    recorder: Option<Arc<dyn CogsRecorder>>,
82}
83
84impl Token {
85    /// Creates a new no-op token, which records nothing.
86    ///
87    /// This is primarily useful for testing.
88    pub fn noop() -> Self {
89        Self {
90            resource: ResourceId::Relay,
91            features: FeatureWeights::none(),
92            measurements: Measurements::start(),
93            recorder: None,
94        }
95    }
96
97    /// Cancels the COGS measurement.
98    pub fn cancel(&mut self) {
99        // No features -> nothing gets attributed.
100        self.update(FeatureWeights::none());
101    }
102
103    /// Starts a categorized measurement.
104    ///
105    /// The measurement is finalized when the returned [`CategoryToken`] is dropped.
106    ///
107    /// Instead of manually starting a categorized measurement, the [`crate::with`]
108    /// macro can be used.
109    pub fn start_category(&mut self, category: impl Category) -> CategoryToken<'_> {
110        CategoryToken {
111            parent: self,
112            start: Instant::now(),
113            category: category.name(),
114        }
115    }
116
117    /// Updates the app features to which the active measurement is attributed to.
118    ///
119    /// # Example:
120    ///
121    /// ```
122    /// # use relay_cogs::{AppFeature, Cogs, ResourceId};
123    /// # struct Item;
124    /// # fn do_something(_: &Item) -> bool { true };
125    ///
126    /// fn process(cogs: &Cogs, item: &Item) {
127    ///     let mut token = cogs.timed(ResourceId::Relay, AppFeature::Unattributed);
128    ///
129    ///     // App feature is only known after some computations.
130    ///     if do_something(item) {
131    ///         token.update(AppFeature::Spans);
132    ///     } else {
133    ///         token.update(AppFeature::Transactions);
134    ///     }
135    /// }
136    /// ```
137    pub fn update<T: Into<FeatureWeights>>(&mut self, features: T) {
138        self.features = features.into();
139    }
140}
141
142impl Drop for Token {
143    fn drop(&mut self) {
144        let Some(recorder) = self.recorder.as_mut() else {
145            return;
146        };
147
148        let measurements = self.measurements.finish();
149
150        for measurement in measurements {
151            for (feature, ratio) in self.features.weights() {
152                let time = measurement.duration.mul_f32(ratio);
153                recorder.record(CogsMeasurement {
154                    resource: self.resource,
155                    feature,
156                    category: measurement.category,
157                    value: Value::Time(time),
158                });
159            }
160        }
161    }
162}
163
164impl fmt::Debug for Token {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.debug_struct("CogsToken")
167            .field("resource", &self.resource)
168            .field("features", &self.features)
169            .finish()
170    }
171}
172
173/// A COGS category.
174pub trait Category {
175    /// String representation of the category.
176    fn name(&self) -> &'static str;
177}
178
179impl Category for &'static str {
180    fn name(&self) -> &'static str {
181        self
182    }
183}
184
185/// A categorized COGS measurement.
186///
187/// Must be started with [`Token::start_category`].
188#[must_use]
189pub struct CategoryToken<'a> {
190    parent: &'a mut Token,
191    start: Instant,
192    category: &'static str,
193}
194
195impl Drop for CategoryToken<'_> {
196    fn drop(&mut self) {
197        self.parent
198            .measurements
199            .add(self.start.elapsed(), self.category);
200    }
201}
202
203impl fmt::Debug for CategoryToken<'_> {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        f.debug_struct("CategoryToken")
206            .field("resource", &self.parent.resource)
207            .field("features", &self.parent.features)
208            .field("category", &self.category)
209            .finish()
210    }
211}
212
213/// A collection of weighted [app features](AppFeature).
214///
215/// Used to attribute a single COGS measurement to multiple features.
216#[derive(Clone)]
217pub struct FeatureWeights(BTreeMap<AppFeature, NonZeroUsize>);
218
219impl FeatureWeights {
220    /// Attributes all measurements to a single [`AppFeature`].
221    pub fn new(feature: AppFeature) -> Self {
222        Self::builder().weight(feature, 1).build()
223    }
224
225    /// Attributes the measurement to nothing.
226    pub fn none() -> Self {
227        Self::builder().build()
228    }
229
230    /// Returns an [`FeatureWeights`] builder.
231    pub fn builder() -> FeatureWeightsBuilder {
232        FeatureWeightsBuilder(Self(Default::default()))
233    }
234
235    /// Merges two instances of [`FeatureWeights`] and sums the contained weights.
236    pub fn merge(mut self, other: Self) -> Self {
237        for (feature, weight) in other.0.into_iter() {
238            if let Some(w) = self.0.get_mut(&feature) {
239                *w = w.saturating_add(weight.get());
240            } else {
241                self.0.insert(feature, weight);
242            }
243        }
244
245        self
246    }
247
248    /// Returns an iterator yielding an app feature and it's associated weight.
249    ///
250    /// Weights are normalized to the total stored weights in the range between `0.0` and `1.0`.
251    /// Used to divide a measurement by the stored weights.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use relay_cogs::{AppFeature, FeatureWeights};
257    /// use std::collections::HashMap;
258    ///
259    /// let app_features = FeatureWeights::builder()
260    ///     .weight(AppFeature::Transactions, 1)
261    ///     .weight(AppFeature::Spans, 1)
262    ///     .build();
263    ///
264    /// let weights: HashMap<AppFeature, f32> = app_features.weights().collect();
265    /// assert_eq!(weights, HashMap::from([(AppFeature::Transactions, 0.5), (AppFeature::Spans, 0.5)]))
266    /// ```
267    pub fn weights(&self) -> impl Iterator<Item = (AppFeature, f32)> + '_ {
268        let total_weight: usize = self.0.values().map(|weight| weight.get()).sum();
269
270        self.0.iter().filter_map(move |(feature, weight)| {
271            if total_weight == 0 {
272                return None;
273            }
274
275            let ratio = (weight.get() as f32 / total_weight as f32).clamp(0.0, 1.0);
276            Some((*feature, ratio))
277        })
278    }
279
280    /// Returns `true` if there are no weights contained.
281    ///
282    /// # Examples
283    ///
284    /// ```
285    /// use relay_cogs::{AppFeature, FeatureWeights};
286    ///
287    /// assert!(FeatureWeights::none().is_empty());
288    /// assert!(!FeatureWeights::new(AppFeature::Spans).is_empty());
289    /// ```
290    pub fn is_empty(&self) -> bool {
291        self.0.is_empty()
292    }
293}
294
295impl fmt::Debug for FeatureWeights {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        write!(f, "FeatureWeights(")?;
298
299        let mut first = true;
300        for (feature, weight) in self.weights() {
301            if !first {
302                first = false;
303                write!(f, ", ")?;
304            }
305            write!(f, "{feature:?}={weight:.2}")?;
306        }
307        write!(f, ")")
308    }
309}
310
311impl From<AppFeature> for FeatureWeights {
312    fn from(value: AppFeature) -> Self {
313        Self::new(value)
314    }
315}
316
317/// A builder for [`FeatureWeights`] which can be used to configure different weights per [`AppFeature`].
318pub struct FeatureWeightsBuilder(FeatureWeights);
319
320impl FeatureWeightsBuilder {
321    /// Increases the `weight` of an [`AppFeature`].
322    pub fn add_weight(&mut self, feature: AppFeature, weight: usize) -> &mut Self {
323        let Some(weight) = NonZeroUsize::new(weight) else {
324            return self;
325        };
326
327        if let Some(previous) = self.0.0.get_mut(&feature) {
328            *previous = previous.saturating_add(weight.get());
329        } else {
330            self.0.0.insert(feature, weight);
331        }
332
333        self
334    }
335
336    /// Sets the specified `weight` for an [`AppFeature`].
337    pub fn weight(&mut self, feature: AppFeature, weight: usize) -> &mut Self {
338        if let Some(weight) = NonZeroUsize::new(weight) {
339            self.0.0.insert(feature, weight);
340        } else {
341            self.0.0.remove(&feature);
342        }
343        self
344    }
345
346    /// Builds and returns the [`FeatureWeights`].
347    pub fn build(&mut self) -> FeatureWeights {
348        std::mem::replace(self, FeatureWeights::builder()).0
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use std::collections::HashMap;
355
356    use super::*;
357    use crate::test::TestRecorder;
358
359    #[test]
360    fn test_cogs_simple() {
361        let recorder = TestRecorder::default();
362        let cogs = Cogs::new(recorder.clone());
363
364        drop(cogs.timed(ResourceId::Relay, AppFeature::Spans));
365
366        let measurements = recorder.measurements();
367        insta::assert_debug_snapshot!(measurements, @r###"
368        [
369            CogsMeasurement {
370                resource: Relay,
371                feature: Spans,
372                category: None,
373                value: Time(
374                    100ns,
375                ),
376            },
377        ]
378        "###);
379    }
380
381    #[test]
382    fn test_cogs_multiple_weights() {
383        let recorder = TestRecorder::default();
384        let cogs = Cogs::new(recorder.clone());
385
386        let f = FeatureWeights::builder()
387            .weight(AppFeature::Spans, 1)
388            .weight(AppFeature::Transactions, 1)
389            .weight(AppFeature::MetricsSpans, 0) // Noop
390            .add_weight(AppFeature::MetricsSpans, 1)
391            .weight(AppFeature::Transactions, 0) // Reset
392            .build();
393        {
394            let _token = cogs.timed(ResourceId::Relay, f);
395            crate::time::advance_millis(50);
396        }
397
398        let measurements = recorder.measurements();
399        insta::assert_debug_snapshot!(measurements, @r###"
400        [
401            CogsMeasurement {
402                resource: Relay,
403                feature: Spans,
404                category: None,
405                value: Time(
406                    25ms,
407                ),
408            },
409            CogsMeasurement {
410                resource: Relay,
411                feature: MetricsSpans,
412                category: None,
413                value: Time(
414                    25ms,
415                ),
416            },
417        ]
418        "###);
419    }
420
421    #[test]
422    fn test_cogs_categorized() {
423        let recorder = TestRecorder::default();
424        let cogs = Cogs::new(recorder.clone());
425
426        let features = FeatureWeights::builder()
427            .weight(AppFeature::Spans, 1)
428            .weight(AppFeature::Errors, 1)
429            .build();
430
431        {
432            let mut token = cogs.timed(ResourceId::Relay, features);
433            crate::time::advance_millis(10);
434            crate::with!(token, "s1", {
435                crate::time::advance_millis(6);
436            });
437            crate::time::advance_millis(20);
438            let _category = token.start_category("s2");
439            crate::time::advance_millis(12);
440        }
441
442        let measurements = recorder.measurements();
443        insta::assert_debug_snapshot!(measurements, @r###"
444        [
445            CogsMeasurement {
446                resource: Relay,
447                feature: Errors,
448                category: None,
449                value: Time(
450                    15ms,
451                ),
452            },
453            CogsMeasurement {
454                resource: Relay,
455                feature: Spans,
456                category: None,
457                value: Time(
458                    15ms,
459                ),
460            },
461            CogsMeasurement {
462                resource: Relay,
463                feature: Errors,
464                category: Some(
465                    "s1",
466                ),
467                value: Time(
468                    3ms,
469                ),
470            },
471            CogsMeasurement {
472                resource: Relay,
473                feature: Spans,
474                category: Some(
475                    "s1",
476                ),
477                value: Time(
478                    3ms,
479                ),
480            },
481            CogsMeasurement {
482                resource: Relay,
483                feature: Errors,
484                category: Some(
485                    "s2",
486                ),
487                value: Time(
488                    6ms,
489                ),
490            },
491            CogsMeasurement {
492                resource: Relay,
493                feature: Spans,
494                category: Some(
495                    "s2",
496                ),
497                value: Time(
498                    6ms,
499                ),
500            },
501        ]
502        "###);
503    }
504
505    #[test]
506    fn test_app_features_none() {
507        let a = FeatureWeights::none();
508        assert_eq!(a.weights().count(), 0);
509    }
510
511    #[test]
512    fn test_app_features_new() {
513        let a = FeatureWeights::new(AppFeature::Spans);
514        assert_eq!(
515            a.weights().collect::<Vec<_>>(),
516            vec![(AppFeature::Spans, 1.0)]
517        );
518    }
519
520    #[test]
521    fn test_app_features_merge() {
522        let a = FeatureWeights::builder()
523            .weight(AppFeature::Spans, 1)
524            .weight(AppFeature::Transactions, 2)
525            .build();
526
527        let b = FeatureWeights::builder()
528            .weight(AppFeature::Spans, 2)
529            .weight(AppFeature::Unattributed, 5)
530            .build();
531
532        let c = FeatureWeights::merge(FeatureWeights::none(), FeatureWeights::merge(a, b));
533
534        let weights: HashMap<_, _> = c.weights().collect();
535        assert_eq!(
536            weights,
537            HashMap::from([
538                (AppFeature::Spans, 0.3),
539                (AppFeature::Transactions, 0.2),
540                (AppFeature::Unattributed, 0.5),
541            ])
542        )
543    }
544}