rskit_observability/
metrics.rs1use std::time::Duration;
2
3use opentelemetry::metrics::{Counter, Gauge, Histogram, UpDownCounter};
4use serde::Deserialize;
5
6use rskit_errors::{AppError, AppResult, ErrorCode};
7
8use crate::tracer::{OtlpProtocol, SERVICE_NAME};
9
10#[derive(Debug, Clone, Deserialize)]
12pub struct MetricsConfig {
13 pub service_name: String,
15 pub export_interval: Duration,
17 pub otlp_endpoint: Option<String>,
19}
20
21pub struct MetricsHandle {
23 meter: opentelemetry::metrics::Meter,
24 _provider: opentelemetry_sdk::metrics::SdkMeterProvider,
25}
26
27impl MetricsHandle {
28 pub fn counter(&self, name: impl Into<String>, description: impl Into<String>) -> Counter<u64> {
30 self.meter
31 .u64_counter(name.into())
32 .with_description(description.into())
33 .build()
34 }
35
36 pub fn histogram(
38 &self,
39 name: impl Into<String>,
40 description: impl Into<String>,
41 ) -> Histogram<f64> {
42 self.meter
43 .f64_histogram(name.into())
44 .with_description(description.into())
45 .build()
46 }
47
48 pub fn gauge(&self, name: impl Into<String>, description: impl Into<String>) -> Gauge<f64> {
50 self.meter
51 .f64_gauge(name.into())
52 .with_description(description.into())
53 .build()
54 }
55
56 pub fn up_down_counter(
58 &self,
59 name: impl Into<String>,
60 description: impl Into<String>,
61 ) -> UpDownCounter<i64> {
62 self.meter
63 .i64_up_down_counter(name.into())
64 .with_description(description.into())
65 .build()
66 }
67}
68
69pub fn init_metrics(cfg: &MetricsConfig) -> AppResult<MetricsHandle> {
71 init_metrics_with_protocol(cfg, OtlpProtocol::Grpc)
72}
73
74pub fn init_metrics_with_protocol(
76 cfg: &MetricsConfig,
77 protocol: OtlpProtocol,
78) -> AppResult<MetricsHandle> {
79 use opentelemetry::metrics::MeterProvider as _;
80 use opentelemetry::{InstrumentationScope, KeyValue};
81 use opentelemetry_sdk::Resource;
82 use opentelemetry_sdk::metrics::SdkMeterProvider;
83
84 let resource = Resource::builder_empty()
85 .with_attributes([KeyValue::new(SERVICE_NAME, cfg.service_name.clone())])
86 .build();
87
88 let builder = SdkMeterProvider::builder().with_resource(resource);
89
90 if let Some(endpoint) = &cfg.otlp_endpoint {
91 #[cfg(not(feature = "otlp"))]
92 {
93 let _ = (endpoint, protocol);
94 Err(AppError::new(
95 ErrorCode::InvalidInput,
96 "OTLP metric exporter requires the `otlp` feature",
97 ))?;
98 }
99
100 #[cfg(feature = "otlp")]
101 {
102 use opentelemetry_otlp::{MetricExporter, WithExportConfig};
103 use opentelemetry_sdk::metrics::PeriodicReader;
104
105 let exporter = match protocol {
106 OtlpProtocol::Grpc => MetricExporter::builder()
107 .with_tonic()
108 .with_endpoint(endpoint)
109 .build(),
110 OtlpProtocol::HttpBinary => MetricExporter::builder()
111 .with_http()
112 .with_protocol(opentelemetry_otlp::Protocol::HttpBinary)
113 .with_endpoint(endpoint)
114 .build(),
115 }
116 .map_err(|e| {
117 AppError::new(ErrorCode::Internal, format!("OTLP metric exporter: {e}"))
118 })?;
119
120 let reader = PeriodicReader::builder(exporter)
121 .with_interval(cfg.export_interval)
122 .build();
123
124 let builder = builder.with_reader(reader);
125 let provider = builder.build();
126 let scope = InstrumentationScope::builder(cfg.service_name.clone()).build();
127 let meter = provider.meter_with_scope(scope);
128
129 return Ok(MetricsHandle {
130 meter,
131 _provider: provider,
132 });
133 }
134 }
135
136 let provider = builder.build();
137 let scope = InstrumentationScope::builder(cfg.service_name.clone()).build();
138 let meter = provider.meter_with_scope(scope);
139
140 Ok(MetricsHandle {
141 meter,
142 _provider: provider,
143 })
144}
145
146#[cfg(test)]
147mod tests {
148 use std::time::Duration;
149
150 use super::*;
151
152 fn config(endpoint: Option<&str>) -> MetricsConfig {
153 MetricsConfig {
154 service_name: "metrics-test".to_string(),
155 export_interval: Duration::from_millis(50),
156 otlp_endpoint: endpoint.map(str::to_string),
157 }
158 }
159
160 #[test]
161 fn no_export_metrics_pipeline_creates_all_instrument_kinds() {
162 let metrics = init_metrics(&config(None)).expect("build metrics provider");
163
164 metrics.counter("requests", "request count").add(1, &[]);
165 metrics
166 .histogram("latency", "request latency")
167 .record(12.5, &[]);
168 metrics.gauge("load", "current load").record(0.75, &[]);
169 metrics
170 .up_down_counter("inflight", "inflight requests")
171 .add(1, &[]);
172 }
173
174 #[cfg(not(feature = "otlp"))]
175 #[test]
176 fn otlp_endpoint_requires_feature() {
177 let error = match init_metrics_with_protocol(
178 &config(Some("http://127.0.0.1:4317")),
179 OtlpProtocol::Grpc,
180 ) {
181 Ok(_) => panic!("otlp disabled should reject metric exporter configuration"),
182 Err(error) => error,
183 };
184
185 assert_eq!(error.code(), ErrorCode::InvalidInput);
186 assert!(error.message().contains("otlp"));
187 }
188
189 #[cfg(feature = "otlp")]
190 #[tokio::test]
191 async fn otlp_metrics_pipeline_builds_for_supported_protocols() {
192 for protocol in [OtlpProtocol::Grpc, OtlpProtocol::HttpBinary] {
193 let metrics =
194 init_metrics_with_protocol(&config(Some("http://127.0.0.1:4317")), protocol)
195 .expect("build otlp metrics provider");
196 metrics.counter("requests", "request count").add(1, &[]);
197 }
198 }
199}