Metered-rs: fast, ergonomic metrics for Rust


#1

Introducing Metered

I’m pleased to announce the release of metered-rs, a crate that helps live measurements of code, inspired by Coda Hale’s Java metrics, with the philosophy that measuring program performance at runtime is valuable, and independent from benchmarking.

Metered is built with the following principles in mind:

  • high ergonomics but no magic: measuring code should just be a matter of annotating code. Metered lets you build your own metric registries from bare metrics, or will generate one using procedural macros. It does not use shared globals or statics.

  • constant, very low overhead: good ergonomics should not come with an overhead; the only overhead is the one imposed by actual metric back-ends themselves (e.g, counters, gauges, histograms), and those provided in Metered do not allocate after initialization. Metered will generate metric registries as regular Rust structs, so there is no lookup involved with finding a metric. Metered provides both unsynchronized and thread-safe metric back-ends so that single-threaded or share-nothing architectures don’t pay for synchronization. Where possible, thread-safe metric back-ends provided by Metered use lock-free data-structures.

  • extensible: metrics are just regular types that implement the Metric trait with a specific behavior. Metered comes with 5 metrics for common use-cases:

    • HitCount: a counter tracking how much a piece of code was hit.
    • ErrorCount: a counter tracking how many errors were returned – (works on any expression returning a std Result)
    • InFlight: a gauge tracking how many requests are active
    • ResponseTime: statistics backed by an histogram of the duration of an expression
    • Throughput: statistics backed by an histogram of how many times an expression is called per second.

When it comes to low-latency, high-range histograms, there’s nothing better than Gil Tene’s High Dynamic Range Histograms and Metered uses @jonhoo’s Rust port by default.


Metered in action

So how does it look in practice?

use metered::{metered, Throughput, HitCount};

#[derive(Default, Debug, serde::Serialize)]
pub struct Biz {
    metrics: BizMetrics,
}

#[metered(registry = BizMetrics)]
impl Biz {
    #[measure([HitCount, Throughput])]
    pub fn biz(&self) {        
        let delay = std::time::Duration::from_millis(rand::random::<u64>() % 200);
        std::thread::sleep(delay);
    }   
}

In the snippet above, we will measure the HitCount and Throughput of the biz method.

This works by first annotating the impl block with the metered annotation and specifying the name Metered should give to the metric registry (here BizMetrics ). Later, Metered will assume the expression to access that repository is self.metrics , hence we need a metrics field with the BizMetrics type in Biz . It would be possible to use another field name by specificying another registry expression, such as #[metered(registry = BizMetrics, registry_expr = self.my_custom_metrics)] .

Then, we must annotate which methods we want to measure using the measure attribute, specifying the metrics we wish to apply: the metrics here are simply types of structures implementing the Metric trait, and you can define your own. Since there is no magic, we must ensure self.metrics can be accessed, and this will only work on methods with a &self or &mut self receiver.

You can read more about this on Metered’s GitHub. Metered has been released and is available on crates.io.


Synattra: a small attribute parser toolkit for Syn

As you might have noticed, the custom attributes of Metered’s procedural macros can refer to Rust expressions, paths, custom keywords… I wrote my custom Syn parsers for attributes, including key/options management, single or multiple values, and extracted that code to its own crate, Synattra.

Synattra uses Syn’s design and traits, which are quite excellent; there is this feeling that the parsing rules are transferred onto Rust’s typing system: once a parser compiles, in my experience it works as expected.


Metrics for any expression

Metered will happily wrap any piece of code you give it to apply its metrics. The metered procedural macro shown above takes function bodies and wrap them properly, but any expression can be measured independently:

use metered::{measure, HitCount, ErrorCount};

#[derive(Default, Debug)]
struct TestMetrics {
    hit_count: HitCount,
    error_count: ErrorCount,
}

fn test(should_fail: bool, metrics: &TestMetrics) -> Result<u32, &'static str> {
    let hit_count = &metrics.hit_count;
    let error_count = &metrics.error_count;
    measure!(hit_count, {
        measure!(error_count, {
            if should_fail {
                Err("Failed!")
            } else {
                Ok(42)
            }
        })
    })
}

The measure! declarative macro is used by the metered procedural macro under-the-hood; it works on any Rust expression (which includes blocks, such as function body blocks). It doesn’t seem like a big deal, since that’s essentially what FnOnce seems to do, except measure will work at the code level, rather than using types: in practice, it lets measure work transparently within async blocks that use await!, which is not possible with FnOnce (await! needs to be in an async generator).

The following code works as expected, in the sense that ResponseTime will report durations between 0 and 2 seconds, even though this code is fully asynchronous and calling baz instantaneously returns a std Future.

    #[measure([ErrorCount, ResponseTime])]
    pub async fn baz(&self, should_fail: bool) -> Result<(), &'static str> {
        let delay = std::time::Duration::from_millis(rand::random::<u64>() % 2000);

        let when = std::time::Instant::now() + delay;
        tokio::await!(tokio::timer::Delay::new(when)).map_err(|_| "Tokio timer error")?;
        if !should_fail {
            println!("baz !");
            Ok(())
        } else {
            Err("I failed!")
        }
    }

but if one where to write the following, similar function, ResponseTime would report 0 durations, since the wrapped code is itself asynchronous:

    #[measure([ResponseTime])]
    pub fn baz(&self, should_fail: bool) -> impl Future<Output = Result<(), &'static str>> {
        async move {
            let delay = std::time::Duration::from_millis(rand::random::<u64>() % 2000);

            let when = std::time::Instant::now() + delay;
            tokio::await!(tokio::timer::Delay::new(when)).map_err(|_| "Tokio timer error")?;
            if !should_fail {
                println!("baz !");
                Ok(())
            } else {
                Err("I failed!")
            }
        }
    }

Aspect-RS

While building Metered, I found that this generic wrapping mechanism coupled with traits and procedural macros provided a quite general, extensible approach to (a portion) aspect-oriented programming in Rust. I tried to extract a bunch of the logic to another crate: aspect-rs. It is still very much of a work-in-progress, but maybe it can be useful to others.


About integration with metric storage or monitoring systems

Right now, metrics are serializable using Serde, which provides an easy way to print them or get them out.

However, there is no module to export to common monitoring systems, but it is planned in separate crates. I wanted to build Metered with ergonomics and performance first. I would gladly discuss about integrating to Prometheus or other systems,


This is all for now. I would appreciate feedback, since these are my first crates, and obviously contributions are welcome.

Simon


#2

Awesome!


#3

I love this approach!