use std::time::Duration;
#[cfg(any(feature = "metrics", test))]
use opentelemetry::{
KeyValue,
metrics::{Counter, Gauge, Histogram},
};
#[cfg(any(feature = "logs", feature = "metrics", test))]
use thread_aware::{Arc, PerCore};
use crate::cache::CacheName;
#[cfg(any(feature = "metrics", test))]
use crate::telemetry::attributes;
#[cfg(any(feature = "logs", feature = "metrics", test))]
#[derive(Clone, Debug)]
pub(crate) struct CacheTelemetryInner {
#[cfg(any(feature = "logs", test))]
pub(crate) logging_enabled: bool,
#[cfg(any(feature = "metrics", test))]
pub(crate) event_counter: Option<Counter<u64>>,
#[cfg(any(feature = "metrics", test))]
pub(crate) operation_duration: Option<Histogram<f64>>,
#[cfg(any(feature = "metrics", test))]
pub(crate) cache_size: Option<Gauge<u64>>,
}
#[cfg(not(any(feature = "logs", feature = "metrics", test)))]
#[derive(Clone, Debug, Default)]
pub(crate) struct CacheTelemetryInner;
#[derive(Clone, Debug)]
pub struct CacheTelemetry {
#[cfg(any(feature = "logs", feature = "metrics", test))]
pub(crate) inner: Arc<CacheTelemetryInner, PerCore>,
#[cfg(not(any(feature = "logs", feature = "metrics", test)))]
#[expect(dead_code, reason = "No-op telemetry when features are disabled")]
pub(crate) inner: CacheTelemetryInner,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum CacheOperation {
Get,
Insert,
Invalidate,
Clear,
}
#[cfg(any(feature = "logs", feature = "metrics", test))]
impl CacheOperation {
pub fn as_str(self) -> &'static str {
match self {
Self::Get => "cache.get",
Self::Insert => "cache.insert",
Self::Invalidate => "cache.invalidate",
Self::Clear => "cache.clear",
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum CacheActivity {
Hit,
Expired,
Miss,
RefreshHit,
RefreshMiss,
Inserted,
Invalidated,
Ok,
Fallback,
FallbackPromotion,
Error,
}
#[cfg(any(feature = "logs", feature = "metrics", test))]
impl CacheActivity {
pub fn as_str(self) -> &'static str {
match self {
Self::Hit => "cache.hit",
Self::Expired => "cache.expired",
Self::Miss => "cache.miss",
Self::RefreshHit => "cache.refresh_hit",
Self::RefreshMiss => "cache.refresh_miss",
Self::Inserted => "cache.inserted",
Self::Invalidated => "cache.invalidated",
Self::Ok => "cache.ok",
Self::Fallback => "cache.fallback",
Self::FallbackPromotion => "cache.fallback_promotion",
Self::Error => "cache.error",
}
}
}
impl CacheTelemetry {
#[cfg_attr(
not(any(feature = "logs", feature = "metrics", test)),
expect(unused_variables, reason = "No-op when both logs and metrics are disabled")
)]
#[cfg_attr(
not(any(feature = "logs", feature = "metrics", test)),
expect(clippy::unused_self, reason = "self is used under feature flags")
)]
#[inline]
pub(crate) fn record(&self, cache_name: CacheName, operation: CacheOperation, activity: CacheActivity, duration: Duration) {
#[cfg(any(feature = "metrics", test))]
{
let attrs = [
KeyValue::new(attributes::CACHE_NAME, cache_name),
KeyValue::new(attributes::CACHE_OPERATION_NAME, operation.as_str()),
KeyValue::new(attributes::CACHE_ACTIVITY_NAME, activity.as_str()),
];
if let Some(c) = &self.inner.event_counter {
c.add(1, &attrs);
}
if let Some(h) = &self.inner.operation_duration {
h.record(duration.as_secs_f64(), &attrs);
}
}
#[cfg(any(feature = "logs", test))]
if self.inner.logging_enabled {
Self::emit(cache_name, operation, activity, duration);
}
}
#[cfg(any(feature = "metrics", test))]
#[cfg_attr(not(test), expect(dead_code, reason = "TODO will be used by future configurable size recording"))]
#[inline]
pub(crate) fn record_size(&self, cache_name: CacheName, size: u64) {
let attrs = [KeyValue::new(attributes::CACHE_NAME, cache_name)];
if let Some(g) = &self.inner.cache_size {
g.record(size, &attrs);
}
}
#[cfg(any(feature = "logs", test))]
fn emit(cache_name: CacheName, operation: CacheOperation, event: CacheActivity, duration: Duration) {
let op = operation.as_str();
let ev = event.as_str();
let duration_ns = duration.as_nanos();
macro_rules! emit_event {
($level:ident) => {
tracing::$level!(
cache.name = cache_name,
cache.operation = op,
cache.activity = ev,
cache.duration_ns = duration_ns,
"cache.event"
)
};
}
match event {
CacheActivity::Error => emit_event!(error),
CacheActivity::Expired
| CacheActivity::RefreshMiss
| CacheActivity::Inserted
| CacheActivity::Invalidated
| CacheActivity::Fallback
| CacheActivity::FallbackPromotion => emit_event!(info),
CacheActivity::Hit | CacheActivity::Miss | CacheActivity::RefreshHit | CacheActivity::Ok => {
emit_event!(debug);
}
}
}
}
#[cfg(test)]
mod tests {
use testing_aids::{LogCapture, MetricTester};
use super::*;
use crate::telemetry::TelemetryConfig;
#[test]
fn cache_operation_as_str() {
assert_eq!(CacheOperation::Get.as_str(), "cache.get");
assert_eq!(CacheOperation::Insert.as_str(), "cache.insert");
assert_eq!(CacheOperation::Invalidate.as_str(), "cache.invalidate");
assert_eq!(CacheOperation::Clear.as_str(), "cache.clear");
}
#[test]
fn cache_activity_as_str() {
assert_eq!(CacheActivity::Hit.as_str(), "cache.hit");
assert_eq!(CacheActivity::Expired.as_str(), "cache.expired");
assert_eq!(CacheActivity::Miss.as_str(), "cache.miss");
assert_eq!(CacheActivity::RefreshHit.as_str(), "cache.refresh_hit");
assert_eq!(CacheActivity::RefreshMiss.as_str(), "cache.refresh_miss");
assert_eq!(CacheActivity::Inserted.as_str(), "cache.inserted");
assert_eq!(CacheActivity::Invalidated.as_str(), "cache.invalidated");
assert_eq!(CacheActivity::Ok.as_str(), "cache.ok");
assert_eq!(CacheActivity::Fallback.as_str(), "cache.fallback");
assert_eq!(CacheActivity::FallbackPromotion.as_str(), "cache.fallback_promotion");
assert_eq!(CacheActivity::Error.as_str(), "cache.error");
}
#[cfg_attr(miri, ignore)]
#[test]
fn metrics_record_emits_correct_attributes() {
let tester = MetricTester::new();
let telemetry = TelemetryConfig::new().with_metrics(tester.meter_provider()).build();
telemetry.record("my_cache", CacheOperation::Get, CacheActivity::Hit, Duration::from_millis(5));
tester.assert_attributes_contain(&[
opentelemetry::KeyValue::new(attributes::CACHE_NAME, "my_cache"),
opentelemetry::KeyValue::new(attributes::CACHE_OPERATION_NAME, CacheOperation::Get.as_str()),
opentelemetry::KeyValue::new(attributes::CACHE_ACTIVITY_NAME, CacheActivity::Hit.as_str()),
]);
}
#[cfg_attr(miri, ignore)]
#[test]
fn metrics_record_size_emits_cache_name() {
let tester = MetricTester::new();
let telemetry = TelemetryConfig::new().with_metrics(tester.meter_provider()).build();
telemetry.record_size("size_test_cache", 42);
tester.assert_attributes_contain(&[opentelemetry::KeyValue::new(attributes::CACHE_NAME, "size_test_cache")]);
}
#[cfg_attr(miri, ignore)]
#[test]
fn logs_emit_contains_all_fields_and_values() {
let capture = LogCapture::new();
let _guard = tracing::subscriber::set_default(capture.subscriber());
let telemetry = TelemetryConfig::new().with_logs().build();
telemetry.record(
"my_test_cache",
CacheOperation::Invalidate,
CacheActivity::Error,
Duration::from_nanos(12345),
);
capture.assert_contains(attributes::CACHE_NAME);
capture.assert_contains(attributes::CACHE_OPERATION_NAME);
capture.assert_contains(attributes::CACHE_ACTIVITY_NAME);
capture.assert_contains("cache.duration_ns");
capture.assert_contains("cache.event");
capture.assert_contains("my_test_cache");
capture.assert_contains(CacheOperation::Invalidate.as_str());
capture.assert_contains(CacheActivity::Error.as_str());
capture.assert_contains("12345");
}
#[cfg_attr(miri, ignore)]
#[test]
fn logs_emit_at_correct_severity_levels() {
let capture = LogCapture::new();
let _guard = tracing::subscriber::set_default(capture.subscriber());
CacheTelemetry::emit("cache", CacheOperation::Get, CacheActivity::Error, Duration::ZERO);
capture.assert_contains("ERROR");
let capture = LogCapture::new();
let _guard = tracing::subscriber::set_default(capture.subscriber());
CacheTelemetry::emit("cache", CacheOperation::Get, CacheActivity::Expired, Duration::ZERO);
capture.assert_contains("INFO");
let capture = LogCapture::new();
let _guard = tracing::subscriber::set_default(capture.subscriber());
CacheTelemetry::emit("cache", CacheOperation::Get, CacheActivity::Hit, Duration::ZERO);
capture.assert_contains("DEBUG");
}
#[cfg_attr(miri, ignore)]
#[test]
fn telemetry_disabled_emits_nothing() {
let telemetry = TelemetryConfig::new().build();
let capture = LogCapture::new();
let _guard = tracing::subscriber::set_default(capture.subscriber());
telemetry.record("cache", CacheOperation::Get, CacheActivity::Hit, Duration::from_secs(1));
assert!(capture.output().is_empty());
}
}