Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consider taking the tracing approach for Key/Label/etc. #405

Open
tobz opened this issue Oct 29, 2023 · 0 comments
Open

Consider taking the tracing approach for Key/Label/etc. #405

tobz opened this issue Oct 29, 2023 · 0 comments

Comments

@tobz
Copy link
Member

tobz commented Oct 29, 2023

This issue is meant to serve as a working space for exploring the potential for shifting metrics to a more tracing-centric design when it comes to how the values for metric labels are captured.

Current Design

How keys and labels are constructed

Working with metrics primarily involves working with Key. The Key describes a unique metric, which is the combination of the metric name and, optionally, metric labels/tags. Each label is wrapped within Label, containing both the key and value.

While users could theoretically talk directly to the globally installed Recorder, metric emission is almost universally mediated through the "macros", a series of procedural (soon declarative) macros used to map to the intended metric type to emit, similar to how tracing/log provide individual macros for the various possible log levels.

When using these macros, they offer a number of ways to be invoked, where the variations support distinctly different ways to provide the labels:

  • fixed string literal key/value e.g. counter!("name", "label_a" => "value_a")
  • expression-based key/value e.g. counter!("name", CONST_LABEL_KEY => local_value)
  • pre-collected labels e.g. counter!("name", dynamic_key_value_tuples)

Static vs (semi-)dynamic labels

In the case where all labels have string literals for key/value, the macros can be crafty and statically create a fixed-size array of those labels, and then just carry around a static reference to them. This is highly performant. However, when not all labels are string literals, or if dynamic labels are being passed in, we have to switch to going through IntoLabels, which involves converting and/or allocating storage to hold all of the Labels.

Even if each label theoretically boils down all static strings, only so much information is available in the macros, and so we might suboptimally go through the IntoLabels/allocation route.

How labels are stored and used

In the optimal case -- everything static -- both the Key and any Labels are constructed statically and we can use a &'static Key when calling Recorder methods like register_counter.

When handling the previously mentioned IntoLabels route, we're allocating Vec<Label> to store all of those labels only to then take a reference to them (by virtue of taking &Key), throwing away the allocation immediately after.

Proposed Design

How tracing splits the definition of fields from their actual values

In tracing, a different approach is taken. Roughly speaking, information about the fields and their shape is collected at compile-time, with a static reference created at the location of the macro callsites. This allows the internal subscriber machinery to be able to quickly and trivially figure out exactly where a tracing event is emitted from, what fields it has, and so on.

Later on, when a tracing event is actually created/emitted, a set of field values is captured. This "value set" is a mapping of field/field value pairs, where the field value itself is tied to an arbitrary lifetime. This allows the value set to borrow data temporarily, whether it's tied to the current stack frame or a 'static reference.

The value set is passed into the internal subscriber machinery, it uses those field/field values as necessary, and then the work is done, the value set can be dropped, and we're done.

Modeling labels after tracing

In metrics, we would take a similar approach, but with some tweaks.

The biggest difference between tracing and metrics is that we allow metric labels to be dynamic, not only in value but also which label keys are present at all. We accomplish this simply by passing a completely owned set of labels -- fully materialized key/value pairs -- which means we can generate them from a static, compile-time built array of labels, or a reference to a slice of labels generated dynamically at runtime where the macro has no information about which labels are present.

In order to compensate for this, we would follow the same design approach -- not requiring an owned wrapper around both the key and value -- with some tweaks to allow us to handle the various ways labels are passed in for a metric.

A new way of storing labels

In this redesign, we would eliminate Label completely. Instead, we would create a new KeyLabels<'a> type that either held the "split" labels (two slices, one for keys and one for values) or the "combined" labels (one slice, with tuples of key/value pairs):

enum KeyLabels<'a> {
    Split {
        keys: &'static [Cow<'static, str>],
        values: &'a [Cow<'a, str>],
    },

    Combined(&'a [(Cow<'a, str>, Cow<'a, str>)]),
}

(Note: SharedString is currently an alias of Cow<'static, str>, but we'd be relaxing that here.. so I'm using Cow<'a, T> to indicate the change from requiring 'static, even though we'd likely lift the lifetime into the alias and end up exposing it as SharedString<'a>.)

For scenarios where we were given labels using the key => value notation in the macros, we would generate "split" labels, where we collected the keys and values separately. This approach works whether the labels are entirely static/const (both key and value) or only the keys are static/const, with the values being borrowed with a non-'static lifetime.

In the case of labels being provided entirely (dynamic), we would require the user to do a little work of lining up the types ahead of time, and then passing us a slice reference to them. This provides a workable solution for either owned values to be shared (such as holding a Vec<(...)> on &self and taking a slice reference) or to construct the slice directly on the stack based on borrowed values.

Overall, what we allow for is defining label keys separately from values when possible, as well as defining them together, and additionally allowing for the ability to reference borrowed values instead of forcing them to be owned or available for 'static.

Supporting changes to Key, Recorder, and recorder implementations

In order to use KeyLabels<'a>, we would naturally need to thread the lifetime parameter through Key, which would then become Key<'a>. Additionally, we would also have to thread through the lifetime for usages of Key<'a> on the various Recorder methods, although similarly to the use of Metadata<'a>, the lifetime could simply be elided in Recorder.

Perhaps more important would be the changes required for recorder implementations themselves.

Currently, recorders can simply clone Key if need be, such as when observing it for the first time and requiring an owned version to store in their registry. However, when looking to name the key type they would be storing, they would be forced to choose a lifetime. The only lifetime that makes sense is 'static given that recorders are installed globally.

As such, we would likely need to provide convenience methods to convert keys to a form that could be named intuitively, such as a method on Key<'a> that returned Key<'static> by cloning itself and creating an owned version.

Everything else should still work as expected, I believe: equality implementations, key hashing, and so on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant