spin_telemetry/metrics.rs
1use anyhow::{Result, bail};
2use opentelemetry_otlp::WithHttpConfig;
3use opentelemetry_sdk::{
4 Resource,
5 metrics::{
6 Aggregation, Instrument, SdkMeterProvider, Stream, new_view,
7 periodic_reader_with_async_runtime::PeriodicReader,
8 },
9 resource::{EnvResourceDetector, ResourceDetector, TelemetryResourceDetector},
10 runtime::Tokio,
11};
12
13use crate::{detector::SpinResourceDetector, env::OtlpProtocol};
14
15/// Re-exported so the metric macros can refer to `$crate::opentelemetry::...`.
16#[doc(hidden)]
17pub use opentelemetry;
18
19/// A custom histogram bucketing for a named metric.
20///
21/// OTel's default histogram boundaries are tuned for millisecond-scale durations (they top out at
22/// 10000). Metrics recorded on a different scale (e.g. a 0.0..=1.0 ratio) need their own
23/// boundaries, or every sample collapses into a single bucket. Callers describe such metrics with
24/// this type and hand them to [`crate::init`]; this crate has no built-in knowledge of which
25/// metrics need it.
26pub struct HistogramBuckets {
27 /// The instrument (metric) name these boundaries apply to.
28 pub metric_name: &'static str,
29 /// Explicit upper bounds for the histogram buckets.
30 pub boundaries: Vec<f64>,
31}
32
33/// Builds an [`SdkMeterProvider`] configured to export to an OTLP collector.
34///
35/// It pulls OTEL configuration from the environment based on the variables defined
36/// [here](https://opentelemetry.io/docs/specs/otel/protocol/exporter/) and
37/// [here](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#general-sdk-configuration).
38///
39/// The caller is responsible for registering the returned provider as the global one (e.g. via
40/// [`opentelemetry::global::set_meter_provider`]). Instruments created by the macros in this
41/// module (e.g. [`counter`](crate::counter)) bind to whatever meter
42/// provider is global *at the time they're first used*, and never rebind afterwards.
43pub(crate) fn metrics_provider(
44 spin_version: String,
45 histogram_buckets: Vec<HistogramBuckets>,
46) -> Result<SdkMeterProvider> {
47 let resource = Resource::builder()
48 .with_detectors(&[
49 // Set service.name from env OTEL_SERVICE_NAME > env OTEL_RESOURCE_ATTRIBUTES > spin
50 // Set service.version from Spin metadata
51 Box::new(SpinResourceDetector::new(spin_version)) as Box<dyn ResourceDetector>,
52 // Sets fields from env OTEL_RESOURCE_ATTRIBUTES
53 Box::new(EnvResourceDetector::new()),
54 // Sets telemetry.sdk{name, language, version}
55 Box::new(TelemetryResourceDetector),
56 ])
57 .build();
58
59 // This will configure the exporter based on the OTEL_EXPORTER_* environment variables. We
60 // currently default to using the HTTP exporter but in the future we could select off of the
61 // combination of OTEL_EXPORTER_OTLP_PROTOCOL and OTEL_EXPORTER_OTLP_TRACES_PROTOCOL to
62 // determine whether we should use http/protobuf or grpc.
63 let exporter = match OtlpProtocol::metrics_protocol_from_env() {
64 OtlpProtocol::Grpc => opentelemetry_otlp::MetricExporter::builder()
65 .with_tonic()
66 .build()?,
67 OtlpProtocol::HttpProtobuf => opentelemetry_otlp::MetricExporter::builder()
68 .with_http()
69 .with_http_client(crate::rustls_reqwest_client()?)
70 .build()?,
71 OtlpProtocol::HttpJson => bail!("http/json OTLP protocol is not supported"),
72 };
73
74 let reader = PeriodicReader::builder(exporter, Tokio).build();
75 let mut provider_builder = SdkMeterProvider::builder()
76 .with_reader(reader)
77 .with_resource(resource);
78 // Apply any caller-supplied histogram bucket overrides as views. This crate stays agnostic
79 // about which metrics need custom boundaries — the owning crate describes them.
80 for buckets in histogram_buckets {
81 provider_builder = provider_builder.with_view(new_view(
82 Instrument::new().name(buckets.metric_name),
83 Stream::new().aggregation(Aggregation::ExplicitBucketHistogram {
84 boundaries: buckets.boundaries,
85 record_min_max: true,
86 }),
87 )?);
88 }
89 Ok(provider_builder.build())
90}
91
92/// Builds a metric name from a dotted-ident path (`spin.foo.bar` => `"spin.foo.bar"`), gets or
93/// creates a static instrument for it, and records a value with the given attributes.
94/// Shared by every public macro in this module.
95///
96/// The `static` is block-scoped to this expansion, so each call site gets its own — safe even in
97/// code that runs many times (e.g. once per request), since the `LazyLock` resolves the
98/// instrument only on the first call and every later call just records against it. It's also
99/// safe to use the same metric name from multiple call sites: the OTel SDK deduplicates
100/// instruments by (meter, name), so the separate statics end up recording to the same series.
101#[doc(hidden)]
102#[macro_export]
103macro_rules! __otel_metric_record {
104 (
105 $T:ty, $builder:ident, $record_method:ident,
106 $metric:ident $(. $suffixes:ident)* = $metric_value:expr $(, $attrs:ident = $values:expr)*
107 ) => {{
108 static INSTRUMENT: ::std::sync::LazyLock<$T> = ::std::sync::LazyLock::new(|| {
109 $crate::metrics::opentelemetry::global::meter(env!("CARGO_PKG_NAME"))
110 .$builder(::std::concat!(
111 ::std::stringify!($metric) $(, ".", ::std::stringify!($suffixes))*
112 ))
113 .build()
114 });
115 INSTRUMENT.$record_method(
116 $metric_value,
117 &[$( $crate::metrics::opentelemetry::KeyValue::new(::std::stringify!($attrs), $values) ),*],
118 );
119 }};
120}
121
122/// Records an increment to the named monotonic counter (as a `u64`) with the given attributes.
123///
124/// ```
125/// spin_telemetry::metrics::counter!(spin.metric_name = 1, metric_attribute = "value");
126/// ```
127#[macro_export]
128macro_rules! counter {
129 ($($tt:tt)*) => {
130 $crate::__otel_metric_record!(
131 $crate::metrics::opentelemetry::metrics::Counter<u64>, u64_counter, add, $($tt)*
132 )
133 };
134}
135
136/// Records a delta to the named counter (as an `i64`) with the given attributes.
137///
138/// Unlike `counter`, the delta may be negative. This maps to OTel's `UpDownCounter`.
139///
140/// ```
141/// spin_telemetry::metrics::up_and_down_counter!(spin.metric_name = -1, metric_attribute = "value");
142/// ```
143#[macro_export]
144macro_rules! up_and_down_counter {
145 ($($tt:tt)*) => {
146 $crate::__otel_metric_record!(
147 $crate::metrics::opentelemetry::metrics::UpDownCounter<i64>, i64_up_down_counter, add, $($tt)*
148 )
149 };
150}
151
152/// Records an additional value (as a `u64`) to the distribution of the named histogram with the
153/// given attributes.
154///
155/// ```
156/// spin_telemetry::metrics::histogram_u64!(spin.metric_name = 1, metric_attribute = "value");
157/// ```
158#[macro_export]
159macro_rules! histogram_u64 {
160 ($($tt:tt)*) => {
161 $crate::__otel_metric_record!(
162 $crate::metrics::opentelemetry::metrics::Histogram<u64>, u64_histogram, record, $($tt)*
163 )
164 };
165}
166
167/// Records an additional value (as an `f64`) to the distribution of the named histogram with the
168/// given attributes.
169///
170/// ```
171/// spin_telemetry::metrics::histogram_f64!(spin.metric_name = 1.5, metric_attribute = "value");
172/// ```
173#[macro_export]
174macro_rules! histogram_f64 {
175 ($($tt:tt)*) => {
176 $crate::__otel_metric_record!(
177 $crate::metrics::opentelemetry::metrics::Histogram<f64>, f64_histogram, record, $($tt)*
178 )
179 };
180}
181
182/// Records the current value (as a `u64`) of the named gauge with the given attributes.
183///
184/// ```
185/// spin_telemetry::metrics::gauge_u64!(spin.metric_name = 1, metric_attribute = "value");
186/// ```
187#[macro_export]
188macro_rules! gauge_u64 {
189 ($($tt:tt)*) => {
190 $crate::__otel_metric_record!(
191 $crate::metrics::opentelemetry::metrics::Gauge<u64>, u64_gauge, record, $($tt)*
192 )
193 };
194}
195
196/// Records the current value (as an `i64`) of the named gauge with the given attributes.
197///
198/// ```
199/// spin_telemetry::metrics::gauge_i64!(spin.metric_name = 1, metric_attribute = "value");
200/// ```
201#[macro_export]
202macro_rules! gauge_i64 {
203 ($($tt:tt)*) => {
204 $crate::__otel_metric_record!(
205 $crate::metrics::opentelemetry::metrics::Gauge<i64>, i64_gauge, record, $($tt)*
206 )
207 };
208}
209
210/// Records the current value (as an `f64`) of the named gauge with the given attributes.
211///
212/// ```
213/// spin_telemetry::metrics::gauge_f64!(spin.metric_name = 1.5, metric_attribute = "value");
214/// ```
215#[macro_export]
216macro_rules! gauge_f64 {
217 ($($tt:tt)*) => {
218 $crate::__otel_metric_record!(
219 $crate::metrics::opentelemetry::metrics::Gauge<f64>, f64_gauge, record, $($tt)*
220 )
221 };
222}
223
224pub use counter;
225pub use gauge_f64;
226pub use gauge_i64;
227pub use gauge_u64;
228pub use histogram_f64;
229pub use histogram_u64;
230pub use up_and_down_counter;
231
232#[cfg(test)]
233mod tests {
234
235 #[test]
236 fn test_macros_compile() {
237 counter!(spin.test_counter = 1, attr = "value");
238 histogram_u64!(spin.test_histogram_u64 = 1, attr = "value");
239 histogram_f64!(spin.test_histogram_f64 = 1.5, attr = "value");
240 gauge_u64!(spin.test_gauge_u64 = 1, attr = "value");
241 gauge_i64!(spin.test_gauge_i64 = -1, attr = "value");
242 gauge_f64!(spin.test_gauge_f64 = 1.5, attr = "value");
243 up_and_down_counter!(spin.test_up_and_down_counter = -1, attr = "value");
244 // repeat to ensure repeat calls still compile
245 up_and_down_counter!(spin.test_up_and_down_counter = -1, attr = "value");
246 }
247}