saluki_context/
resolver.rs

1use std::{num::NonZeroUsize, sync::Arc, time::Duration};
2
3use saluki_common::{
4    cache::{weight::ItemCountWeighter, Cache, CacheBuilder},
5    collections::PrehashedHashSet,
6    hash::NoopU64BuildHasher,
7};
8use saluki_error::{generic_error, GenericError};
9use saluki_metrics::{static_metrics, Counter, Gauge};
10use stringtheory::{
11    interning::{GenericMapInterner, Interner as _},
12    CheapMetaString, MetaString,
13};
14use tokio::time::sleep;
15use tracing::debug;
16
17use crate::{
18    context::{Context, ContextInner},
19    hash::{hash_context_with_host_and_seen, ContextKey, TagSetKey},
20    origin::{OriginTagsResolver, RawOrigin},
21    tags::{SharedTagSet, TagSet},
22};
23
24// SAFETY: We know, unquestionably, that this value is not zero.
25const DEFAULT_CONTEXT_RESOLVER_CACHED_CONTEXTS_LIMIT: NonZeroUsize = NonZeroUsize::new(500_000).unwrap();
26
27// SAFETY: We know, unquestionably, that this value is not zero.
28const DEFAULT_CONTEXT_RESOLVER_INTERNER_CAPACITY_BYTES: NonZeroUsize = NonZeroUsize::new(2 * 1024 * 1024).unwrap();
29
30const SEEN_HASHSET_INITIAL_CAPACITY: usize = 128;
31
32type ContextCache = Cache<ContextKey, Context, ItemCountWeighter, NoopU64BuildHasher>;
33type TagSetCache = Cache<TagSetKey, SharedTagSet, ItemCountWeighter, NoopU64BuildHasher>;
34
35#[static_metrics(prefix = context_resolver, labels(resolver_id))]
36#[derive(Clone)]
37struct Telemetry {
38    interner_capacity_bytes: Gauge,
39    interner_len_bytes: Gauge,
40    interner_entries: Gauge,
41    #[metric(level = debug)]
42    intern_fallback_total: Counter,
43    #[metric(level = debug)]
44    resolved_existing_context_total: Counter,
45    #[metric(level = debug)]
46    resolved_new_context_total: Counter,
47    active_contexts: Gauge,
48    #[metric(level = debug)]
49    resolved_existing_tagset_total: Counter,
50    #[metric(level = debug)]
51    resolved_new_tagset_total: Counter,
52}
53
54/// Builder for creating a [`ContextResolver`].
55///
56/// # Missing
57///
58/// - Support for configuring the size limit of cached contexts.
59pub struct ContextResolverBuilder {
60    name: String,
61    caching_enabled: bool,
62    cached_contexts_limit: Option<NonZeroUsize>,
63    idle_context_expiration: Option<Duration>,
64    interner_capacity_bytes: Option<NonZeroUsize>,
65    allow_heap_allocations: Option<bool>,
66    tags_resolver: Option<TagsResolver>,
67    interner: Option<GenericMapInterner>,
68    origin_tags_resolver: Option<Arc<dyn OriginTagsResolver>>,
69    telemetry_enabled: bool,
70}
71
72impl ContextResolverBuilder {
73    /// Creates a new `ContextResolverBuilder` with the given resolver name.
74    ///
75    /// The resolver name _should_ be unique, but it isn't required to be. Metrics for the resolver will be
76    /// emitted using the given name, so in cases where the name isn't unique, those metrics will be aggregated
77    /// together and it won't be possible to distinguish between the different resolvers.
78    ///
79    /// # Errors
80    ///
81    /// If the given resolver name is empty, an error is returned.
82    pub fn from_name<S: Into<String>>(name: S) -> Result<Self, GenericError> {
83        let name = name.into();
84        if name.is_empty() {
85            return Err(generic_error!("resolver name must not be empty"));
86        }
87
88        Ok(Self {
89            name,
90            caching_enabled: true,
91            cached_contexts_limit: None,
92            idle_context_expiration: None,
93            interner_capacity_bytes: None,
94            allow_heap_allocations: None,
95            tags_resolver: None,
96            interner: None,
97            origin_tags_resolver: None,
98            telemetry_enabled: true,
99        })
100    }
101
102    /// Sets whether or not to enable caching of resolved contexts.
103    ///
104    /// [`ContextResolver`] provides two main benefits: consistent behavior for resolving contexts (interning, origin
105    /// tags, etc), and the caching of those resolved contexts to speed up future resolutions. However, caching contexts
106    /// means that we pay a memory cost for the cache itself, even if the contexts aren't ever reused or are seen
107    /// infrequently. While expiration can help free up cache capacity, it can't help recover the memory used by the
108    /// underlying cache data structure once they have expanded to hold the contexts.
109    ///
110    /// Disabling caching allows normal resolving to take place without the overhead of caching the contexts. This can
111    /// lead to lower average memory usage, as contexts will only live as long as they're needed, but it will reduce
112    /// memory determinism as memory will be allocated for every resolved context (minus interned strings), which means
113    /// that resolving the same context ten times in a row will result in ten separate allocations, and so on.
114    ///
115    /// Defaults to caching enabled.
116    pub fn without_caching(mut self) -> Self {
117        self.caching_enabled = false;
118        self.idle_context_expiration = None;
119        self
120    }
121
122    /// Sets the limit on the number of cached contexts.
123    ///
124    /// This is the maximum number of resolved contexts that can be cached at any given time. This limit doesn't affect
125    /// the total number of contexts that can be _alive_ at any given time, which is dependent on the interner capacity
126    /// and whether or not heap allocations are allowed.
127    ///
128    /// Caching contexts is beneficial when the same context is resolved frequently, and it's generally worth
129    /// allowing for higher limits on cached contexts when heap allocations are allowed, as this can better amortize the
130    /// cost of those heap allocations.
131    ///
132    /// If value is zero, caching will be disabled, and no contexts will be cached. This is equivalent to calling
133    /// `without_caching`.
134    ///
135    /// Defaults to 500,000.
136    pub fn with_cached_contexts_limit(mut self, limit: usize) -> Self {
137        match NonZeroUsize::new(limit) {
138            Some(limit) => {
139                self.cached_contexts_limit = Some(limit);
140                self
141            }
142            None => self.without_caching(),
143        }
144    }
145
146    /// Sets the time before contexts are considered "idle" and eligible for expiration.
147    ///
148    /// This controls how long a context will be kept in the cache after its last access or creation time. This value is
149    /// a lower bound, as contexts eligible for expiration may not be expired immediately. Contexts may still be removed
150    /// prior to their natural expiration time if the cache is full and evictions are required to make room for a new
151    /// context.
152    ///
153    /// Defaults to no expiration.
154    pub fn with_idle_context_expiration(mut self, time_to_idle: Duration) -> Self {
155        self.idle_context_expiration = Some(time_to_idle);
156        self
157    }
158
159    /// Sets the capacity of the string interner, in bytes.
160    ///
161    /// This is the maximum number of bytes that the interner will use for interning strings that are present in
162    /// contexts being resolved. This capacity may or may not be allocated entirely when the resolver is built, but the
163    /// interner won't exceed the configured capacity when allocating any backing storage.
164    ///
165    /// This value directly impacts the number of contexts that can be resolved when heap allocations are disabled, as
166    /// all resolved contexts must either have values (name or tags) that can be inlined or interned. Once the interner
167    /// is full, contexts may fail to be resolved if heap allocations are disabled.
168    ///
169    /// The optimal value will almost always be workload-dependent, but a good starting point can be to estimate around
170    /// 150 - 200 bytes per context based on empirical measurements around common metric name and tag lengths. This
171    /// translate to around 5000 unique contexts per 1 MB of interner size.
172    ///
173    /// Defaults to 2 MB.
174    pub fn with_interner_capacity_bytes(mut self, capacity: NonZeroUsize) -> Self {
175        self.interner_capacity_bytes = Some(capacity);
176        self
177    }
178
179    /// Sets whether or not to allow heap allocations when interning strings.
180    ///
181    /// In cases where the interner is full, this setting determines whether or not we refuse to resolve a context, or
182    /// if we allow it be resolved by allocating strings on the heap. When heap allocations are enabled, the amount of
183    /// memory that can be used by the interner is effectively unlimited, as contexts that can't be interned will be
184    /// simply spill to the heap instead of being limited in any way.
185    ///
186    /// Defaults to `true`.
187    pub fn with_heap_allocations(mut self, allow: bool) -> Self {
188        self.allow_heap_allocations = Some(allow);
189        self
190    }
191
192    /// Sets the tags resolver.
193    ///
194    /// Defaults to unset.
195    pub fn with_tags_resolver(mut self, resolver: Option<TagsResolver>) -> Self {
196        self.tags_resolver = resolver;
197        self
198    }
199
200    /// Sets whether or not to enable telemetry for this resolver.
201    ///
202    /// Reporting the telemetry of the resolver requires running an asynchronous task to override adding additional
203    /// overhead in the hot path of resolving contexts. In some cases, it may be cumbersome to always create the
204    /// resolver in an asynchronous context so that the telemetry task can be spawned. This method allows disabling
205    /// telemetry reporting in those cases.
206    ///
207    /// Defaults to telemetry enabled.
208    pub fn without_telemetry(mut self) -> Self {
209        self.telemetry_enabled = false;
210        self
211    }
212
213    /// Sets the interner to use for this resolver.
214    ///
215    /// If an interner isn't provided, an interner will be created in [`ContextResolverBuilder::build`]
216    pub fn with_interner(mut self, interner: GenericMapInterner) -> Self {
217        self.interner = Some(interner);
218        self
219    }
220
221    /// Configures a [`ContextResolverBuilder`] that's suitable for tests.
222    ///
223    /// This configures the builder with the following defaults:
224    ///
225    /// - resolver name of "noop"
226    /// - unlimited cache capacity
227    /// - no-op interner (all strings are heap-allocated)
228    /// - heap allocations allowed
229    /// - telemetry disabled
230    ///
231    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate
232    /// testing scenarios.
233    pub fn for_tests() -> Self {
234        ContextResolverBuilder::from_name("noop")
235            .expect("resolver name not empty")
236            .with_cached_contexts_limit(usize::MAX)
237            .with_interner_capacity_bytes(NonZeroUsize::new(1).expect("not zero"))
238            .with_heap_allocations(true)
239            .with_tags_resolver(Some(TagsResolverBuilder::for_tests().build()))
240            .without_telemetry()
241    }
242
243    /// Builds a [`ContextResolver`] from the current configuration.
244    pub fn build(self) -> ContextResolver {
245        let interner_capacity_bytes = self
246            .interner_capacity_bytes
247            .unwrap_or(DEFAULT_CONTEXT_RESOLVER_INTERNER_CAPACITY_BYTES);
248
249        let interner = match self.interner {
250            Some(interner) => interner,
251            None => GenericMapInterner::new(interner_capacity_bytes),
252        };
253
254        let cached_context_limit = self
255            .cached_contexts_limit
256            .unwrap_or(DEFAULT_CONTEXT_RESOLVER_CACHED_CONTEXTS_LIMIT);
257
258        let allow_heap_allocations = self.allow_heap_allocations.unwrap_or(true);
259
260        let telemetry = Telemetry::new(&self.name);
261        telemetry
262            .interner_capacity_bytes()
263            .set(interner.capacity_bytes() as f64);
264
265        // NOTE: We should switch to using a size-based weighter so that we can do more firm bounding of what we cache.
266        let context_cache = CacheBuilder::from_identifier(format!("{}/contexts", self.name))
267            .expect("cache identifier cannot possibly be empty")
268            .with_capacity(cached_context_limit)
269            .with_time_to_idle(self.idle_context_expiration)
270            .with_hasher::<NoopU64BuildHasher>()
271            .with_telemetry(self.telemetry_enabled)
272            .build();
273
274        // If no tags resolver is provided, we need to create one using the same interner used for the context resolver.
275        let tags_resolver = match self.tags_resolver {
276            Some(tags_resolver) => tags_resolver,
277            None => TagsResolverBuilder::new(format!("{}/tags", self.name), interner.clone())
278                .expect("tags resolver name not empty")
279                .with_cached_tagsets_limit(cached_context_limit.get())
280                .with_idle_tagsets_expiration(self.idle_context_expiration.unwrap_or_default())
281                .with_heap_allocations(allow_heap_allocations)
282                .with_origin_tags_resolver(self.origin_tags_resolver.clone())
283                .build(),
284        };
285
286        if self.telemetry_enabled {
287            tokio::spawn(drive_telemetry(interner.clone(), telemetry.clone()));
288        }
289
290        ContextResolver {
291            telemetry,
292            interner,
293            caching_enabled: self.caching_enabled,
294            context_cache,
295            hash_seen_buffer: PrehashedHashSet::with_capacity_and_hasher(
296                SEEN_HASHSET_INITIAL_CAPACITY,
297                NoopU64BuildHasher,
298            ),
299            allow_heap_allocations,
300            tags_resolver,
301        }
302    }
303}
304
305/// A centralized store for resolved contexts.
306///
307/// Contexts are the combination of a name and a set of tags. They're used to identify a specific metric series. As
308/// contexts are constructed entirely of strings, they're expensive to construct in a way that allows sending between
309/// tasks, as this usually requires allocations. Additionally, some context are "hotter" than others, used frequently by
310/// the applications/services sending us metrics.
311///
312/// In order to optimize this, the context resolver is responsible for both interning the strings involved where
313/// possible, as well as keeping a map of contexts that can be referred to with a cheap handle. We can cheaply search
314/// for an existing context without needing to allocate an entirely new one, and get a clone of the handle to use going
315/// forward.
316///
317/// # Design
318///
319/// `ContextResolver` specifically manages interning and mapping of contexts. It can be cheaply cloned itself.
320///
321/// In order to resolve a context, `resolve` must be called which requires taking a lock to check for an existing
322/// context. A read/write lock is used in order to prioritize lookups over inserts, as lookups are expected to be more
323/// common given how often a given context is used and resolved.
324///
325/// Once a context is resolved, a cheap handle -- `Context` -- is returned. This handle, like `ContextResolver`, can be
326/// cheaply cloned. It points directly to the underlying context data (name and tags) and provides access to these
327/// components.
328pub struct ContextResolver {
329    telemetry: Telemetry,
330    interner: GenericMapInterner,
331    caching_enabled: bool,
332    context_cache: ContextCache,
333    hash_seen_buffer: PrehashedHashSet<u64>,
334    allow_heap_allocations: bool,
335    tags_resolver: TagsResolver,
336}
337
338impl ContextResolver {
339    fn intern<S>(&self, s: S) -> Option<MetaString>
340    where
341        S: AsRef<str> + CheapMetaString,
342    {
343        // Try to cheaply clone the string, and if that fails, try to intern it. If that fails, then we fall back to
344        // allocating it on the heap if we allow it.
345        s.try_cheap_clone()
346            .or_else(|| self.interner.try_intern(s.as_ref()).map(MetaString::from))
347            .or_else(|| {
348                self.allow_heap_allocations.then(|| {
349                    // Heap spill: with `allow_context_heap_allocations` true (the default), a full interner silently
350                    // falls back to the heap, so the bounded-memory guarantee no longer holds. Anchor that this path is
351                    // reached so the unbounded-growth behavior is observable.
352                    saluki_antithesis::sometimes!(
353                        true,
354                        "context string interner spilled to the heap (unbounded under default config)"
355                    );
356                    self.telemetry.intern_fallback_total().increment(1);
357                    MetaString::from(s.as_ref())
358                })
359            })
360    }
361
362    fn create_context_key_with_host<N, H, I, I2, T, T2>(
363        &mut self, name: N, host: Option<&H>, tags: I, origin_tags: I2,
364    ) -> (ContextKey, TagSetKey)
365    where
366        N: AsRef<str>,
367        H: AsRef<str>,
368        I: IntoIterator<Item = T>,
369        T: AsRef<str>,
370        I2: IntoIterator<Item = T2>,
371        T2: AsRef<str>,
372    {
373        hash_context_with_host_and_seen(
374            name.as_ref(),
375            host.map(AsRef::as_ref),
376            tags,
377            origin_tags,
378            &mut self.hash_seen_buffer,
379        )
380    }
381
382    fn create_context<N, H>(
383        &self, key: ContextKey, name: N, host: Option<H>, context_tags: SharedTagSet, origin_tags: SharedTagSet,
384    ) -> Option<Context>
385    where
386        N: AsRef<str> + CheapMetaString,
387        H: AsRef<str> + CheapMetaString,
388    {
389        // Intern the name, host, and tags of the context.
390        let context_name = self.intern(name)?;
391        let context_host = match host {
392            Some(host) => Some(self.intern(host)?),
393            None => None,
394        };
395
396        self.telemetry.resolved_new_context_total().increment(1);
397        self.telemetry.active_contexts().increment(1);
398
399        Some(Context::from_inner(ContextInner::from_parts(
400            key,
401            context_name,
402            context_host,
403            context_tags.into(),
404            origin_tags.into(),
405            self.telemetry.active_contexts().clone(),
406        )))
407    }
408
409    /// Resolves the given context.
410    ///
411    /// If the context hasn't yet been resolved, the name and tags are interned and a new context is created and
412    /// stored. Otherwise, the existing context is returned. If an origin tags resolver is configured, and origin info
413    /// is available, any enriched tags will be added to the context.
414    ///
415    /// `None` may be returned if the interner is full and outside allocations are disallowed. See
416    /// `allow_heap_allocations` for more information.
417    pub fn resolve<N, I, T>(&mut self, name: N, tags: I, maybe_origin: Option<RawOrigin<'_>>) -> Option<Context>
418    where
419        N: AsRef<str> + CheapMetaString,
420        I: IntoIterator<Item = T> + Clone,
421        T: AsRef<str> + CheapMetaString,
422    {
423        // Try and resolve our origin tags from the provided origin information, if any.
424        let origin_tags = self.tags_resolver.resolve_origin_tags(maybe_origin);
425
426        self.resolve_inner(name, tags, origin_tags)
427    }
428
429    /// Resolves the given context using the provided origin tags.
430    ///
431    /// If the context hasn't yet been resolved, the name and tags are interned and a new context is created and
432    /// stored. Otherwise, the existing context is returned. The provided origin tags are used to enrich the context.
433    ///
434    /// `None` may be returned if the interner is full and outside allocations are disallowed. See
435    /// `allow_heap_allocations` for more information.
436    ///
437    /// ## Origin tags resolver mismatch
438    ///
439    /// When passing in origin tags, they will be inherently tied to a specific `OriginTagsResolver`, which may
440    /// differ from the configured origin tags resolver in this context resolver. This means that the context that's
441    /// generated and cached may not be reused in the future if an attempt is made to resolve it using the raw origin
442    /// information instead.
443    ///
444    /// This method is intended primarily to allow for resolving contexts in a consistent way while _reusing_ the origin
445    /// tags from another context, such as when remapping the name and/or instrumented tags of a given metric, while
446    /// maintaining its origin association.
447    pub fn resolve_with_origin_tags<N, I, T>(
448        &mut self, name: N, tags: I, origin_tags: impl Into<SharedTagSet>,
449    ) -> Option<Context>
450    where
451        N: AsRef<str> + CheapMetaString,
452        I: IntoIterator<Item = T> + Clone,
453        T: AsRef<str> + CheapMetaString,
454    {
455        self.resolve_inner(name, tags, origin_tags.into())
456    }
457
458    /// Resolves the given context using the provided host and origin tags.
459    pub fn resolve_with_host_and_origin_tags<N, H, I, T>(
460        &mut self, name: N, host: H, tags: I, origin_tags: impl Into<SharedTagSet>,
461    ) -> Option<Context>
462    where
463        N: AsRef<str> + CheapMetaString,
464        H: AsRef<str> + CheapMetaString,
465        I: IntoIterator<Item = T> + Clone,
466        T: AsRef<str> + CheapMetaString,
467    {
468        self.resolve_inner_with_host(name, Some(host), tags, origin_tags.into())
469    }
470
471    /// Resolves the given context using the provided optional host and origin tags.
472    pub fn resolve_with_optional_host_and_origin_tags<N, H, I, T>(
473        &mut self, name: N, host: Option<H>, tags: I, origin_tags: impl Into<SharedTagSet>,
474    ) -> Option<Context>
475    where
476        N: AsRef<str> + CheapMetaString,
477        H: AsRef<str> + CheapMetaString,
478        I: IntoIterator<Item = T> + Clone,
479        T: AsRef<str> + CheapMetaString,
480    {
481        self.resolve_inner_with_host(name, host, tags, origin_tags.into())
482    }
483
484    /// Resolves the given context with an explicit host dimension.
485    ///
486    /// The host participates in context identity but is not part of the visible tag set.
487    pub fn resolve_with_host<N, H, I, T>(
488        &mut self, name: N, host: H, tags: I, maybe_origin: Option<RawOrigin<'_>>,
489    ) -> Option<Context>
490    where
491        N: AsRef<str> + CheapMetaString,
492        H: AsRef<str> + CheapMetaString,
493        I: IntoIterator<Item = T> + Clone,
494        T: AsRef<str> + CheapMetaString,
495    {
496        self.resolve_with_optional_host(name, Some(host), tags, maybe_origin)
497    }
498
499    /// Resolves the given context with an optional host dimension.
500    pub fn resolve_with_optional_host<N, H, I, T>(
501        &mut self, name: N, host: Option<H>, tags: I, maybe_origin: Option<RawOrigin<'_>>,
502    ) -> Option<Context>
503    where
504        N: AsRef<str> + CheapMetaString,
505        H: AsRef<str> + CheapMetaString,
506        I: IntoIterator<Item = T> + Clone,
507        T: AsRef<str> + CheapMetaString,
508    {
509        let origin_tags = self.tags_resolver.resolve_origin_tags(maybe_origin);
510
511        self.resolve_inner_with_host(name, host, tags, origin_tags)
512    }
513
514    fn resolve_inner<N, I, T>(&mut self, name: N, tags: I, origin_tags: SharedTagSet) -> Option<Context>
515    where
516        N: AsRef<str> + CheapMetaString,
517        I: IntoIterator<Item = T> + Clone,
518        T: AsRef<str> + CheapMetaString,
519    {
520        self.resolve_inner_with_host(name, None::<&str>, tags, origin_tags)
521    }
522
523    fn resolve_inner_with_host<N, H, I, T>(
524        &mut self, name: N, host: Option<H>, tags: I, origin_tags: SharedTagSet,
525    ) -> Option<Context>
526    where
527        N: AsRef<str> + CheapMetaString,
528        H: AsRef<str> + CheapMetaString,
529        I: IntoIterator<Item = T> + Clone,
530        T: AsRef<str> + CheapMetaString,
531    {
532        let (context_key, tagset_key) =
533            self.create_context_key_with_host(&name, host.as_ref(), tags.clone(), &origin_tags);
534
535        // Fast path to avoid looking up the context in the cache if caching is disabled.
536        if !self.caching_enabled {
537            let tag_set = self.tags_resolver.create_tag_set(tags).unwrap_or_default();
538
539            let context = self.create_context(context_key, name, host, tag_set, origin_tags)?;
540
541            debug!(?context_key, ?context, "Resolved new non-cached context.");
542            return Some(context);
543        }
544
545        match self.context_cache.get(&context_key) {
546            Some(context) => {
547                self.telemetry.resolved_existing_context_total().increment(1);
548                Some(context)
549            }
550            None => {
551                // Try seeing if we have the tagset cached already, and create it if not.
552                let tag_set = match self.tags_resolver.get_tag_set(tagset_key) {
553                    Some(tag_set) => {
554                        self.telemetry.resolved_existing_tagset_total().increment(1);
555                        tag_set
556                    }
557                    None => {
558                        // If the tagset is not cached, we need to create it.
559                        let tag_set = self.tags_resolver.create_tag_set(tags.clone()).unwrap_or_default();
560
561                        self.tags_resolver.insert_tag_set(tagset_key, tag_set.clone());
562
563                        tag_set
564                    }
565                };
566
567                let context = self.create_context(context_key, name, host, tag_set, origin_tags)?;
568                self.context_cache.insert(context_key, context.clone());
569
570                debug!(?context_key, ?context, "Resolved new context.");
571                Some(context)
572            }
573        }
574    }
575}
576
577impl Clone for ContextResolver {
578    fn clone(&self) -> Self {
579        Self {
580            telemetry: self.telemetry.clone(),
581            interner: self.interner.clone(),
582            caching_enabled: self.caching_enabled,
583            context_cache: self.context_cache.clone(),
584            hash_seen_buffer: PrehashedHashSet::with_capacity_and_hasher(
585                SEEN_HASHSET_INITIAL_CAPACITY,
586                NoopU64BuildHasher,
587            ),
588            allow_heap_allocations: self.allow_heap_allocations,
589            tags_resolver: self.tags_resolver.clone(),
590        }
591    }
592}
593
594async fn drive_telemetry(interner: GenericMapInterner, telemetry: Telemetry) {
595    loop {
596        sleep(Duration::from_secs(1)).await;
597
598        telemetry.interner_entries().set(interner.len() as f64);
599        telemetry
600            .interner_capacity_bytes()
601            .set(interner.capacity_bytes() as f64);
602        telemetry.interner_len_bytes().set(interner.len_bytes() as f64);
603    }
604}
605
606/// A builder for a tag resolver.
607pub struct TagsResolverBuilder {
608    name: String,
609    caching_enabled: bool,
610    cached_tagset_limit: Option<NonZeroUsize>,
611    idle_tagset_expiration: Option<Duration>,
612    allow_heap_allocations: Option<bool>,
613    origin_tags_resolver: Option<Arc<dyn OriginTagsResolver>>,
614    telemetry_enabled: bool,
615    interner: GenericMapInterner,
616}
617
618impl TagsResolverBuilder {
619    /// Creates a new [`TagsResolverBuilder`] with the given name and interner.
620    pub fn new<S: Into<String>>(name: S, interner: GenericMapInterner) -> Result<Self, GenericError> {
621        let name = name.into();
622        if name.is_empty() {
623            return Err(generic_error!("resolver name must not be empty"));
624        }
625
626        Ok(Self {
627            name,
628            caching_enabled: true,
629            cached_tagset_limit: None,
630            idle_tagset_expiration: None,
631            allow_heap_allocations: None,
632            origin_tags_resolver: None,
633            telemetry_enabled: true,
634            interner,
635        })
636    }
637
638    /// Sets the interner to use for this resolver.
639    ///
640    /// This is used when we want to use a separate internet for tagsets, different from the one used for contexts.
641    ///
642    /// Defaults to using the interner passed to the builder.
643    pub fn with_interner(mut self, interner: GenericMapInterner) -> Self {
644        self.interner = interner;
645        self
646    }
647
648    /// Sets whether or not to enable caching of resolved tag sets.
649    ///
650    /// [`TagsResolver`] provides two main benefits: consistent behavior for resolving tag sets (interning, origin
651    /// tags, etc), and the caching of those resolved tag sets to speed up future resolutions. However, caching tag
652    /// sets means that we pay a memory cost for the cache itself, even if the tag sets aren't ever reused or are seen
653    /// infrequently. While expiration can help free up cache capacity, it can't help recover the memory used by the
654    /// underlying cache data structure once they have expanded to hold the tag sets.
655    ///
656    /// Disabling caching allows normal resolving to take place without the overhead of caching the tag sets. This can
657    /// lead to lower average memory usage, as tag sets will only live as long as they're needed, but it will reduce
658    /// memory determinism as memory will be allocated for every resolved tag set (minus interned strings), which means
659    /// that resolving the same tag set ten times in a row will result in ten separate allocations, and so on.
660    ///
661    /// Defaults to caching enabled.
662    pub fn without_caching(mut self) -> Self {
663        self.caching_enabled = false;
664        self.idle_tagset_expiration = None;
665        self
666    }
667
668    /// Sets the limit on the number of cached tagsets.
669    ///
670    /// This is the maximum number of resolved tag sets that can be cached at any given time. This limit doesn't affect
671    /// the total number of tag sets that can be _alive_ at any given time, which is dependent on the interner capacity
672    /// and whether or not heap allocations are allowed.
673    ///
674    /// Caching tag sets is beneficial when the same tag set is resolved frequently, and it's generally worth
675    /// allowing for higher limits on cached tag sets when heap allocations are allowed, as this can better amortize the
676    /// cost of those heap allocations.
677    ///
678    /// If value is zero, caching will be disabled, and no tag sets will be cached. This is equivalent to calling
679    /// `without_caching`.
680    ///
681    /// Defaults to 500,000.
682    pub fn with_cached_tagsets_limit(mut self, limit: usize) -> Self {
683        match NonZeroUsize::new(limit) {
684            Some(limit) => {
685                self.cached_tagset_limit = Some(limit);
686                self
687            }
688            None => self.without_caching(),
689        }
690    }
691
692    /// Sets the time before tag sets are considered "idle" and eligible for expiration.
693    ///
694    /// This controls how long a tag set will be kept in the cache after its last access or creation time. This value is
695    /// a lower bound, as tag sets eligible for expiration may not be expired immediately. Tag sets may still be removed
696    /// prior to their natural expiration time if the cache is full and evictions are required to make room for a new
697    /// context.
698    ///
699    /// Defaults to no expiration.
700    pub fn with_idle_tagsets_expiration(mut self, time_to_idle: Duration) -> Self {
701        self.idle_tagset_expiration = Some(time_to_idle);
702        self
703    }
704
705    /// Sets whether or not to allow heap allocations when interning strings.
706    ///
707    /// In cases where the interner is full, this setting determines whether or not we refuse to resolve a context, or
708    /// if we allow it be resolved by allocating strings on the heap. When heap allocations are enabled, the amount of
709    /// memory that can be used by the interner is effectively unlimited, as contexts that can't be interned will be
710    /// simply spill to the heap instead of being limited in any way.
711    ///
712    /// Defaults to `true`.
713    pub fn with_heap_allocations(mut self, allow: bool) -> Self {
714        self.allow_heap_allocations = Some(allow);
715        self
716    }
717
718    /// Sets the origin tags resolver to use when building a context.
719    ///
720    /// In some cases, metrics, events, and service checks may have enriched tags based on their origin -- the
721    /// application/host/container/etc that emitted the metric -- which has to be considered when building the context
722    /// itself. As this can be expensive, it's useful to split the logic of actually grabbing the enriched tags based
723    /// on the available origin info into a separate phase, and implementation, that can run separately from the
724    /// initial hash-based approach of checking if a context has already been resolved.
725    ///
726    /// When set, any origin information provided will be considered during hashing when looking up a context, and any
727    /// enriched tags attached to the detected origin will be accessible from the context.
728    ///
729    /// Defaults to unset.
730    pub fn with_origin_tags_resolver(mut self, resolver: Option<Arc<dyn OriginTagsResolver>>) -> Self {
731        self.origin_tags_resolver = resolver;
732        self
733    }
734
735    /// Sets whether or not to enable telemetry for this resolver.
736    ///
737    /// Reporting the telemetry of the resolver requires running an asynchronous task to override adding additional
738    /// overhead in the hot path of resolving contexts. In some cases, it may be cumbersome to always create the
739    /// resolver in an asynchronous context so that the telemetry task can be spawned. This method allows disabling
740    /// telemetry reporting in those cases.
741    ///
742    /// Defaults to telemetry enabled.
743    pub fn without_telemetry(mut self) -> Self {
744        self.telemetry_enabled = false;
745        self
746    }
747
748    /// Builds a [`TagsResolver`] from the current configuration.
749    pub fn build(self) -> TagsResolver {
750        let cached_tagsets_limit = self
751            .cached_tagset_limit
752            .unwrap_or(DEFAULT_CONTEXT_RESOLVER_CACHED_CONTEXTS_LIMIT);
753
754        let allow_heap_allocations = self.allow_heap_allocations.unwrap_or(true);
755
756        let telemetry = Telemetry::new(self.name.clone());
757        telemetry
758            .interner_capacity_bytes()
759            .set(self.interner.capacity_bytes() as f64);
760
761        let tagset_cache = CacheBuilder::from_identifier(format!("{}/tagsets", self.name))
762            .expect("cache identifier cannot possibly be empty")
763            .with_capacity(cached_tagsets_limit)
764            .with_time_to_idle(self.idle_tagset_expiration)
765            .with_hasher::<NoopU64BuildHasher>()
766            .with_telemetry(self.telemetry_enabled)
767            .build();
768
769        TagsResolver {
770            telemetry,
771            interner: self.interner,
772            caching_enabled: self.caching_enabled,
773            tagset_cache,
774            origin_tags_resolver: self.origin_tags_resolver,
775            allow_heap_allocations,
776        }
777    }
778
779    /// Configures a [`TagsResolverBuilder`] that's suitable for tests.
780    ///
781    /// This configures the builder with the following defaults:
782    ///
783    /// - resolver name of "noop"
784    /// - unlimited cache capacity
785    /// - no-op interner (all strings are heap-allocated)
786    /// - heap allocations allowed
787    /// - telemetry disabled
788    ///
789    /// This is generally only useful for testing purposes, and is exposed publicly in order to be used in cross-crate
790    /// testing scenarios.
791    pub fn for_tests() -> Self {
792        TagsResolverBuilder::new("noop", GenericMapInterner::new(NonZeroUsize::new(1).expect("not zero")))
793            .expect("resolver name not empty")
794            .with_cached_tagsets_limit(usize::MAX)
795            .with_heap_allocations(true)
796            .without_telemetry()
797    }
798}
799
800/// A resolver for tags.
801pub struct TagsResolver {
802    telemetry: Telemetry,
803    interner: GenericMapInterner,
804    caching_enabled: bool,
805    tagset_cache: TagSetCache,
806    origin_tags_resolver: Option<Arc<dyn OriginTagsResolver>>,
807    allow_heap_allocations: bool,
808}
809
810impl TagsResolver {
811    fn intern<S>(&self, s: S) -> Option<MetaString>
812    where
813        S: AsRef<str> + CheapMetaString,
814    {
815        // Try to cheaply clone the string, and if that fails, try to intern it. If that fails, then we fall back to
816        // allocating it on the heap if we allow it.
817        s.try_cheap_clone()
818            .or_else(|| self.interner.try_intern(s.as_ref()).map(MetaString::from))
819            .or_else(|| {
820                self.allow_heap_allocations.then(|| {
821                    // Heap spill: with `allow_context_heap_allocations` true (the default), a full interner silently
822                    // falls back to the heap, so the bounded-memory guarantee no longer holds. Anchor that this path is
823                    // reached so the unbounded-growth behavior is observable.
824                    saluki_antithesis::sometimes!(
825                        true,
826                        "tag string interner spilled to the heap (unbounded under default config)"
827                    );
828                    self.telemetry.intern_fallback_total().increment(1);
829                    MetaString::from(s.as_ref())
830                })
831            })
832    }
833
834    /// Creates a new tag set from the given tags.
835    ///
836    /// This will intern the tags, and then return a shared tag set. If the interner is full, and heap allocations are
837    /// not allowed, then this will return `None`.
838    ///
839    /// If heap allocations are allowed, then this will return a shared tag set, and the tag set will be cached.
840    pub fn create_tag_set<I, T>(&mut self, tags: I) -> Option<SharedTagSet>
841    where
842        I: IntoIterator<Item = T>,
843        T: AsRef<str> + CheapMetaString,
844    {
845        let mut tag_set = TagSet::default();
846        for tag in tags {
847            let tag = self.intern(tag)?;
848            tag_set.insert_tag(tag);
849        }
850
851        self.telemetry.resolved_new_tagset_total().increment(1);
852
853        Some(tag_set.into_shared())
854    }
855
856    /// Resolves the origin tags for the given origin.
857    ///
858    /// This will return the origin tags for the given origin, or an empty tag set if no origin tags resolver is set.
859    pub fn resolve_origin_tags(&self, maybe_origin: Option<RawOrigin<'_>>) -> SharedTagSet {
860        self.origin_tags_resolver
861            .as_ref()
862            .and_then(|resolver| maybe_origin.map(|origin| resolver.resolve_origin_tags(origin)))
863            .unwrap_or_default()
864    }
865
866    fn get_tag_set(&self, key: TagSetKey) -> Option<SharedTagSet> {
867        self.tagset_cache.get(&key)
868    }
869
870    fn insert_tag_set(&self, key: TagSetKey, tag_set: SharedTagSet) {
871        self.tagset_cache.insert(key, tag_set);
872    }
873}
874
875impl Clone for TagsResolver {
876    fn clone(&self) -> Self {
877        Self {
878            telemetry: self.telemetry.clone(),
879            interner: self.interner.clone(),
880            caching_enabled: self.caching_enabled,
881            tagset_cache: self.tagset_cache.clone(),
882            origin_tags_resolver: self.origin_tags_resolver.clone(),
883            allow_heap_allocations: self.allow_heap_allocations,
884        }
885    }
886}
887
888#[cfg(test)]
889mod tests {
890    use metrics::{SharedString, Unit};
891    use metrics_util::{
892        debugging::{DebugValue, DebuggingRecorder},
893        CompositeKey,
894    };
895    use saluki_common::hash::hash_single_fast;
896
897    use super::*;
898    use crate::tags::Tag;
899
900    fn get_gauge_value(metrics: &[(CompositeKey, Option<Unit>, Option<SharedString>, DebugValue)], key: &str) -> f64 {
901        metrics
902            .iter()
903            .find(|(k, _, _, _)| k.key().name() == key)
904            .map(|(_, _, _, value)| match value {
905                DebugValue::Gauge(value) => value.into_inner(),
906                other => panic!("expected a gauge, got: {:?}", other),
907            })
908            .unwrap_or_else(|| panic!("no metric found with key: {}", key))
909    }
910
911    struct DummyOriginTagsResolver;
912
913    impl OriginTagsResolver for DummyOriginTagsResolver {
914        fn resolve_origin_tags(&self, origin: RawOrigin<'_>) -> SharedTagSet {
915            let origin_key = hash_single_fast(origin);
916
917            let mut tags = TagSet::default();
918            tags.insert_tag(format!("origin_key:{}", origin_key));
919            tags.into_shared()
920        }
921    }
922
923    #[test]
924    fn basic() {
925        let mut resolver = ContextResolverBuilder::for_tests().build();
926
927        // Create two distinct contexts with the same name but different tags:
928        let name = "metric_name";
929        let tags1: [&str; 0] = [];
930        let tags2 = ["tag1"];
931
932        assert_ne!(&tags1[..], &tags2[..]);
933
934        let context1 = resolver
935            .resolve(name, &tags1[..], None)
936            .expect("should not fail to resolve");
937        let context2 = resolver
938            .resolve(name, &tags2[..], None)
939            .expect("should not fail to resolve");
940
941        // The contexts should not be equal to each other, and should have distinct underlying pointers to the shared
942        // context state:
943        assert_ne!(context1, context2);
944        assert!(!context1.ptr_eq(&context2));
945
946        // If we create the context references again, we _should_ get back the same contexts as before:
947        let context1_redo = resolver
948            .resolve(name, &tags1[..], None)
949            .expect("should not fail to resolve");
950        let context2_redo = resolver
951            .resolve(name, &tags2[..], None)
952            .expect("should not fail to resolve");
953
954        assert_ne!(context1_redo, context2_redo);
955        assert_eq!(context1, context1_redo);
956        assert_eq!(context2, context2_redo);
957        assert!(context1.ptr_eq(&context1_redo));
958        assert!(context2.ptr_eq(&context2_redo));
959    }
960
961    #[test]
962    fn tag_order() {
963        let mut resolver = ContextResolverBuilder::for_tests().build();
964
965        // Create two distinct contexts with the same name and tags, but with the tags in a different order:
966        let name = "metric_name";
967        let tags1 = ["tag1", "tag2"];
968        let tags2 = ["tag2", "tag1"];
969
970        assert_ne!(&tags1[..], &tags2[..]);
971
972        let context1 = resolver
973            .resolve(name, &tags1[..], None)
974            .expect("should not fail to resolve");
975        let context2 = resolver
976            .resolve(name, &tags2[..], None)
977            .expect("should not fail to resolve");
978
979        // The contexts should be equal to each other, and should have the same underlying pointer to the shared context
980        // state:
981        assert_eq!(context1, context2);
982        assert!(context1.ptr_eq(&context2));
983    }
984
985    #[test]
986    fn host_affects_identity_but_not_visible_tags() {
987        let mut resolver = ContextResolverBuilder::for_tests().build();
988
989        let context1 = resolver
990            .resolve_with_host("metric_name", "host-a", &[] as &[&str], None)
991            .expect("should not fail to resolve");
992        let context2 = resolver
993            .resolve_with_host("metric_name", "host-b", &[] as &[&str], None)
994            .expect("should not fail to resolve");
995        let context1_redo = resolver
996            .resolve_with_host("metric_name", "host-a", &[] as &[&str], None)
997            .expect("should not fail to resolve");
998
999        assert_ne!(context1, context2);
1000        assert_eq!(context1, context1_redo);
1001        assert!(context1.ptr_eq(&context1_redo));
1002        assert_eq!(context1.host(), Some("host-a"));
1003        assert_eq!(context2.host(), Some("host-b"));
1004        assert!(context1.tags().is_empty());
1005        assert!(context2.tags().is_empty());
1006
1007        let mut uncached_resolver = ContextResolverBuilder::for_tests().without_caching().build();
1008        let uncached1 = uncached_resolver
1009            .resolve_with_host("metric_name", "host-a", &[] as &[&str], None)
1010            .expect("should not fail to resolve");
1011        let uncached2 = uncached_resolver
1012            .resolve_with_host("metric_name", "host-b", &[] as &[&str], None)
1013            .expect("should not fail to resolve");
1014
1015        assert_ne!(uncached1, uncached2);
1016        assert!(!uncached1.ptr_eq(&uncached2));
1017    }
1018
1019    #[test]
1020    fn host_survives_rewrites() {
1021        let mut resolver = ContextResolverBuilder::for_tests().build();
1022
1023        let context1 = resolver
1024            .resolve_with_host("metric_name", "host-a", &["env:prod"][..], None)
1025            .expect("should not fail to resolve");
1026        let context2 = resolver
1027            .resolve_with_host("metric_name", "host-b", &["env:prod"][..], None)
1028            .expect("should not fail to resolve");
1029
1030        assert_ne!(context1, context2);
1031        let service_tag_set = TagSet::from_iter([Tag::from("service:api")]);
1032        assert_ne!(context1.with_name("renamed"), context2.with_name("renamed"));
1033        assert_ne!(
1034            context1.with_tags(service_tag_set.clone()),
1035            context2.with_tags(service_tag_set)
1036        );
1037
1038        let mut context1_filtered = context1.clone();
1039        let mut context2_filtered = context2.clone();
1040        let mut state1 = crate::context::TagSetMutViewState::new();
1041        let mut state2 = crate::context::TagSetMutViewState::new();
1042        {
1043            let mut view = context1_filtered.tags_mut_view(&mut state1);
1044            view.retain_tags(|_| false);
1045            view.finish();
1046        }
1047        {
1048            let mut view = context2_filtered.tags_mut_view(&mut state2);
1049            view.retain_tags(|_| false);
1050            view.finish();
1051        }
1052
1053        assert_ne!(context1_filtered, context2_filtered);
1054        assert!(context1_filtered.tags().is_empty());
1055        assert!(context2_filtered.tags().is_empty());
1056        assert_eq!(context1_filtered.host(), Some("host-a"));
1057        assert_eq!(context2_filtered.host(), Some("host-b"));
1058    }
1059
1060    #[test]
1061    fn active_contexts() {
1062        let recorder = DebuggingRecorder::new();
1063        let snapshotter = recorder.snapshotter();
1064
1065        // Create our resolver and then create a context, which will have its metrics attached to our local recorder:
1066        let context = metrics::with_local_recorder(&recorder, || {
1067            let mut resolver = ContextResolverBuilder::for_tests().build();
1068            resolver
1069                .resolve("name", &["tag"][..], None)
1070                .expect("should not fail to resolve")
1071        });
1072
1073        // We should be able to see that the active context count is one, representing the context we created:
1074        let metrics_before = snapshotter.snapshot().into_vec();
1075        let active_contexts = get_gauge_value(&metrics_before, Telemetry::active_contexts_name());
1076        assert_eq!(active_contexts, 1.0);
1077
1078        // Now drop the context, and observe the active context count is negative one, representing the context we dropped:
1079        drop(context);
1080        let metrics_after = snapshotter.snapshot().into_vec();
1081        let active_contexts = get_gauge_value(&metrics_after, Telemetry::active_contexts_name());
1082        assert_eq!(active_contexts, -1.0);
1083    }
1084
1085    #[test]
1086    fn duplicate_tags() {
1087        let mut resolver = ContextResolverBuilder::for_tests().build();
1088
1089        // Two contexts with the same name, but each with a different set of duplicate tags:
1090        let name = "metric_name";
1091        let tags1 = ["tag1"];
1092        let tags1_duplicated = ["tag1", "tag1"];
1093        let tags2 = ["tag2"];
1094        let tags2_duplicated = ["tag2", "tag2"];
1095
1096        let context1 = resolver
1097            .resolve(name, &tags1[..], None)
1098            .expect("should not fail to resolve");
1099        let context1_duplicated = resolver
1100            .resolve(name, &tags1_duplicated[..], None)
1101            .expect("should not fail to resolve");
1102        let context2 = resolver
1103            .resolve(name, &tags2[..], None)
1104            .expect("should not fail to resolve");
1105        let context2_duplicated = resolver
1106            .resolve(name, &tags2_duplicated[..], None)
1107            .expect("should not fail to resolve");
1108
1109        // Each non-duplicated/duplicated context pair should be equal to one another:
1110        assert_eq!(context1, context1_duplicated);
1111        assert_eq!(context2, context2_duplicated);
1112
1113        // Each pair should not be equal to the other pair, however.
1114        //
1115        // What we're asserting here is that, if we didn't handle duplicate tags correctly, the XOR hashing of [tag1,
1116        // tag1] and [tag2, tag2] would result in the same hash value, since the second duplicate hash of tag1/tag2
1117        // would cancel out the first... and thus all that would be left is the hash of the name itself, which is the
1118        // same in this test. This would lead to the contexts being equal, which is obviously wrong.
1119        //
1120        // If we're handling duplicates properly, then the resulting context hashes _shouldn't_ be equal.
1121        assert_ne!(context1, context2);
1122        assert_ne!(context1_duplicated, context2_duplicated);
1123        assert_ne!(context1, context2_duplicated);
1124        assert_ne!(context2, context1_duplicated);
1125    }
1126
1127    #[test]
1128    fn differing_origins_with_without_resolver() {
1129        // Create a regular context resolver, without any origin tags resolver, which should result in contexts being
1130        // the same so long as the name and tags are the same, disregarding any difference in origin information:
1131        let mut resolver = ContextResolverBuilder::for_tests().build();
1132
1133        let name = "metric_name";
1134        let tags = ["tag1"];
1135        let mut origin1 = RawOrigin::default();
1136        origin1.set_local_data("container1");
1137        let mut origin2 = RawOrigin::default();
1138        origin2.set_local_data("container2");
1139
1140        let context1 = resolver
1141            .resolve(name, &tags[..], Some(origin1.clone()))
1142            .expect("should not fail to resolve");
1143        let context2 = resolver
1144            .resolve(name, &tags[..], Some(origin2.clone()))
1145            .expect("should not fail to resolve");
1146
1147        assert_eq!(context1, context2);
1148
1149        let tags_resolver = TagsResolverBuilder::for_tests()
1150            .with_origin_tags_resolver(Some(Arc::new(DummyOriginTagsResolver)))
1151            .build();
1152        // Now build a context resolver with an origin tags resolver that trivially returns the hash of the origin info
1153        // as a tag, which should result in differeing sets of origin tags between the two origins, thus no longer
1154        // comparing as equal:
1155        let mut resolver = ContextResolverBuilder::for_tests()
1156            .with_tags_resolver(Some(tags_resolver))
1157            .build();
1158
1159        let context1 = resolver
1160            .resolve(name, &tags[..], Some(origin1))
1161            .expect("should not fail to resolve");
1162        let context2 = resolver
1163            .resolve(name, &tags[..], Some(origin2))
1164            .expect("should not fail to resolve");
1165
1166        assert_ne!(context1, context2);
1167    }
1168
1169    #[test]
1170    fn caching_disabled() {
1171        let tags_resolver = TagsResolverBuilder::for_tests()
1172            .with_origin_tags_resolver(Some(Arc::new(DummyOriginTagsResolver)))
1173            .build();
1174        let mut resolver = ContextResolverBuilder::for_tests()
1175            .without_caching()
1176            .with_tags_resolver(Some(tags_resolver))
1177            .build();
1178
1179        let name = "metric_name";
1180        let tags = ["tag1"];
1181        let mut origin1 = RawOrigin::default();
1182        origin1.set_local_data("container1");
1183
1184        // Create a context with caching disabled, and verify that the context is not cached:
1185        let context1 = resolver
1186            .resolve(name, &tags[..], Some(origin1.clone()))
1187            .expect("should not fail to resolve");
1188        assert_eq!(resolver.context_cache.len(), 0);
1189
1190        // Create a second context with the same name and tags, and verify that it is not cached:
1191        let context2 = resolver
1192            .resolve(name, &tags[..], Some(origin1))
1193            .expect("should not fail to resolve");
1194        assert_eq!(resolver.context_cache.len(), 0);
1195
1196        // The contexts should be equal to each other, but the underlying `Arc` pointers should be different since
1197        // they're two distinct contexts in terms of not being cached:
1198        assert_eq!(context1, context2);
1199        assert!(!context1.ptr_eq(&context2));
1200    }
1201
1202    #[test]
1203    fn cheaply_cloneable_name_and_tags() {
1204        const BIG_TAG_ONE: &str = "long-tag-that-cannot-be-inlined-just-to-be-doubly-sure-on-top-of-being-static";
1205        const BIG_TAG_TWO: &str = "another-long-boye-that-we-are-also-sure-wont-be-inlined-and-we-stand-on-that";
1206
1207        // Create a context resolver with a proper string interner configured:
1208        let mut resolver = ContextResolverBuilder::for_tests()
1209            .with_interner_capacity_bytes(NonZeroUsize::new(1024).expect("not zero"))
1210            .build();
1211
1212        // Create our context with cheaply cloneable tags, aka static strings:
1213        let name = MetaString::from_static("long-metric-name-that-shouldnt-be-inlined-and-should-end-up-interned");
1214        let tags = [
1215            MetaString::from_static(BIG_TAG_ONE),
1216            MetaString::from_static(BIG_TAG_TWO),
1217        ];
1218        assert!(tags[0].is_cheaply_cloneable());
1219        assert!(tags[1].is_cheaply_cloneable());
1220
1221        // Make sure the interner is empty before we resolve the context, and that it's empty afterwards, since we
1222        // should be able to cheaply clone both the metric name and both tags:
1223        assert_eq!(resolver.interner.len(), 0);
1224        assert_eq!(resolver.interner.len_bytes(), 0);
1225
1226        let context = resolver
1227            .resolve(&name, &tags[..], None)
1228            .expect("should not fail to resolve");
1229        assert_eq!(resolver.interner.len(), 0);
1230        assert_eq!(resolver.interner.len_bytes(), 0);
1231
1232        // And just a sanity check that we have the expected name and tags in the context:
1233        assert_eq!(context.name(), &name);
1234
1235        let context_tags = context.tags();
1236        assert_eq!(context_tags.len(), 2);
1237        assert!(context_tags.has_tag(&tags[0]));
1238        assert!(context_tags.has_tag(&tags[1]));
1239    }
1240}