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):
(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.
This issue is meant to serve as a working space for exploring the potential for shifting
metrics
to a moretracing
-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
. TheKey
describes a unique metric, which is the combination of the metric name and, optionally, metric labels/tags. Each label is wrapped withinLabel
, 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 howtracing
/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:
counter!("name", "label_a" => "value_a")
counter!("name", CONST_LABEL_KEY => local_value)
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 theLabel
s.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 anyLabel
s are constructed statically and we can use a&'static Key
when callingRecorder
methods likeregister_counter
.When handling the previously mentioned
IntoLabels
route, we're allocatingVec<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 valuesIn
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
andmetrics
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 newKeyLabels<'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):(Note:
SharedString
is currently an alias ofCow<'static, str>
, but we'd be relaxing that here.. so I'm usingCow<'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 asSharedString<'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 implementationsIn order to use
KeyLabels<'a>
, we would naturally need to thread the lifetime parameter throughKey
, which would then becomeKey<'a>
. Additionally, we would also have to thread through the lifetime for usages ofKey<'a>
on the variousRecorder
methods, although similarly to the use ofMetadata<'a>
, the lifetime could simply be elided inRecorder
.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 returnedKey<'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.