spin_telemetry/metrics.rs
1use anyhow::{Result, bail};
2use opentelemetry_otlp::{WithExportConfig, 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::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/// Aside from `endpoint`, it pulls OTEL configuration from the environment based on the
36/// variables defined [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/// `endpoint` overrides the collector endpoint the environment would otherwise select (via
40/// `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`), for callers that take
41/// their own explicit configuration (e.g. a CLI flag) rather than expecting operators to set
42/// OTel's environment variables directly. Pass `None` to use the environment as normal.
43///
44/// `resource_detectors` lets the caller contribute additional resource attributes.
45/// They run before this crate's own detectors, which set fields from `OTEL_RESOURCE_ATTRIBUTES`
46/// and `telemetry.sdk{name, language, version}`.
47///
48/// The caller is responsible for registering the returned provider as the global one (e.g. via
49/// [`opentelemetry::global::set_meter_provider`]). Instruments created by the macros in this
50/// module (e.g. [`counter`](crate::counter)) bind to whatever meter
51/// provider is global *at the time they're first used*, and never rebind afterwards.
52///
53/// Exposed publicly so embedders that manage their own [tracing::Subscriber] (and so can't call
54/// [`crate::init`], which also installs one) can still set up Spin's metrics: build a provider
55/// with this function and register it themselves, alongside their own, via
56/// [`opentelemetry::global::set_meter_provider`].
57pub fn metrics_provider(
58 endpoint: Option<String>,
59 resource_detectors: Vec<Box<dyn ResourceDetector>>,
60 histogram_buckets: Vec<HistogramBuckets>,
61) -> Result<SdkMeterProvider> {
62 let resource = Resource::builder()
63 .with_detectors(
64 &resource_detectors
65 .into_iter()
66 .chain([
67 // Sets fields from env OTEL_RESOURCE_ATTRIBUTES
68 Box::new(EnvResourceDetector::new()) as Box<dyn ResourceDetector>,
69 // Sets telemetry.sdk{name, language, version}
70 Box::new(TelemetryResourceDetector),
71 ])
72 .collect::<Vec<_>>(),
73 )
74 .build();
75
76 // This will configure the exporter based on the OTEL_EXPORTER_* environment variables. We
77 // currently default to using the HTTP exporter but in the future we could select off of the
78 // combination of OTEL_EXPORTER_OTLP_PROTOCOL and OTEL_EXPORTER_OTLP_TRACES_PROTOCOL to
79 // determine whether we should use http/protobuf or grpc.
80 let exporter = match OtlpProtocol::metrics_protocol_from_env() {
81 OtlpProtocol::Grpc => {
82 let mut builder = opentelemetry_otlp::MetricExporter::builder().with_tonic();
83 if let Some(endpoint) = endpoint {
84 builder = builder.with_endpoint(endpoint);
85 }
86 builder.build()?
87 }
88 OtlpProtocol::HttpProtobuf => {
89 let mut builder = opentelemetry_otlp::MetricExporter::builder()
90 .with_http()
91 .with_http_client(crate::rustls_reqwest_client()?);
92 if let Some(endpoint) = endpoint {
93 builder = builder.with_endpoint(endpoint);
94 }
95 builder.build()?
96 }
97 OtlpProtocol::HttpJson => bail!("http/json OTLP protocol is not supported"),
98 };
99
100 let reader = PeriodicReader::builder(exporter, Tokio).build();
101 let mut provider_builder = SdkMeterProvider::builder()
102 .with_reader(reader)
103 .with_resource(resource);
104 // Apply any caller-supplied histogram bucket overrides as views. This crate stays agnostic
105 // about which metrics need custom boundaries — the owning crate describes them.
106 for buckets in histogram_buckets {
107 provider_builder = provider_builder.with_view(new_view(
108 Instrument::new().name(buckets.metric_name),
109 Stream::new().aggregation(Aggregation::ExplicitBucketHistogram {
110 boundaries: buckets.boundaries,
111 record_min_max: true,
112 }),
113 )?);
114 }
115 Ok(provider_builder.build())
116}
117
118/// Builds a metric name from a dotted-ident path (`spin.foo.bar` => `"spin.foo.bar"`), gets or
119/// creates a static instrument for it, and records a value with the given attributes.
120/// Shared by every public macro in this module.
121///
122/// The `static` is block-scoped to this expansion, so each call site gets its own — safe even in
123/// code that runs many times (e.g. once per request), since the `LazyLock` resolves the
124/// instrument only on the first call and every later call just records against it. It's also
125/// safe to use the same metric name from multiple call sites: the OTel SDK deduplicates
126/// instruments by (meter, name), so the separate statics end up recording to the same series.
127#[doc(hidden)]
128#[macro_export]
129macro_rules! __otel_metric_record {
130 (
131 $T:ty, $builder:ident, $record_method:ident,
132 $metric:ident $(. $suffixes:ident)* = $metric_value:expr $(, $attrs:ident = $values:expr)*
133 ) => {{
134 static INSTRUMENT: ::std::sync::LazyLock<$T> = ::std::sync::LazyLock::new(|| {
135 $crate::metrics::opentelemetry::global::meter(env!("CARGO_PKG_NAME"))
136 .$builder(::std::concat!(
137 ::std::stringify!($metric) $(, ".", ::std::stringify!($suffixes))*
138 ))
139 .build()
140 });
141 INSTRUMENT.$record_method(
142 $metric_value,
143 &[$( $crate::metrics::opentelemetry::KeyValue::new(::std::stringify!($attrs), $values) ),*],
144 );
145 }};
146}
147
148/// Records an increment to the named monotonic counter (as a `u64`) with the given attributes.
149///
150/// ```
151/// spin_telemetry::metrics::counter!(spin.metric_name = 1, metric_attribute = "value");
152/// ```
153#[macro_export]
154macro_rules! counter {
155 ($($tt:tt)*) => {
156 $crate::__otel_metric_record!(
157 $crate::metrics::opentelemetry::metrics::Counter<u64>, u64_counter, add, $($tt)*
158 )
159 };
160}
161
162/// Records a delta to the named counter (as an `i64`) with the given attributes.
163///
164/// Unlike `counter`, the delta may be negative. This maps to OTel's `UpDownCounter`.
165///
166/// ```
167/// spin_telemetry::metrics::up_and_down_counter!(spin.metric_name = -1, metric_attribute = "value");
168/// ```
169#[macro_export]
170macro_rules! up_and_down_counter {
171 ($($tt:tt)*) => {
172 $crate::__otel_metric_record!(
173 $crate::metrics::opentelemetry::metrics::UpDownCounter<i64>, i64_up_down_counter, add, $($tt)*
174 )
175 };
176}
177
178/// Records an additional value (as a `u64`) to the distribution of the named histogram with the
179/// given attributes.
180///
181/// ```
182/// spin_telemetry::metrics::histogram_u64!(spin.metric_name = 1, metric_attribute = "value");
183/// ```
184#[macro_export]
185macro_rules! histogram_u64 {
186 ($($tt:tt)*) => {
187 $crate::__otel_metric_record!(
188 $crate::metrics::opentelemetry::metrics::Histogram<u64>, u64_histogram, record, $($tt)*
189 )
190 };
191}
192
193/// Records an additional value (as an `f64`) to the distribution of the named histogram with the
194/// given attributes.
195///
196/// ```
197/// spin_telemetry::metrics::histogram_f64!(spin.metric_name = 1.5, metric_attribute = "value");
198/// ```
199#[macro_export]
200macro_rules! histogram_f64 {
201 ($($tt:tt)*) => {
202 $crate::__otel_metric_record!(
203 $crate::metrics::opentelemetry::metrics::Histogram<f64>, f64_histogram, record, $($tt)*
204 )
205 };
206}
207
208/// Records the current value (as a `u64`) of the named gauge with the given attributes.
209///
210/// ```
211/// spin_telemetry::metrics::gauge_u64!(spin.metric_name = 1, metric_attribute = "value");
212/// ```
213#[macro_export]
214macro_rules! gauge_u64 {
215 ($($tt:tt)*) => {
216 $crate::__otel_metric_record!(
217 $crate::metrics::opentelemetry::metrics::Gauge<u64>, u64_gauge, record, $($tt)*
218 )
219 };
220}
221
222/// Records the current value (as an `i64`) of the named gauge with the given attributes.
223///
224/// ```
225/// spin_telemetry::metrics::gauge_i64!(spin.metric_name = 1, metric_attribute = "value");
226/// ```
227#[macro_export]
228macro_rules! gauge_i64 {
229 ($($tt:tt)*) => {
230 $crate::__otel_metric_record!(
231 $crate::metrics::opentelemetry::metrics::Gauge<i64>, i64_gauge, record, $($tt)*
232 )
233 };
234}
235
236/// Records the current value (as an `f64`) of the named gauge with the given attributes.
237///
238/// ```
239/// spin_telemetry::metrics::gauge_f64!(spin.metric_name = 1.5, metric_attribute = "value");
240/// ```
241#[macro_export]
242macro_rules! gauge_f64 {
243 ($($tt:tt)*) => {
244 $crate::__otel_metric_record!(
245 $crate::metrics::opentelemetry::metrics::Gauge<f64>, f64_gauge, record, $($tt)*
246 )
247 };
248}
249
250pub use counter;
251pub use gauge_f64;
252pub use gauge_i64;
253pub use gauge_u64;
254pub use histogram_f64;
255pub use histogram_u64;
256pub use up_and_down_counter;
257
258#[cfg(test)]
259mod tests {
260
261 #[test]
262 fn test_macros_compile() {
263 counter!(spin.test_counter = 1, attr = "value");
264 histogram_u64!(spin.test_histogram_u64 = 1, attr = "value");
265 histogram_f64!(spin.test_histogram_f64 = 1.5, attr = "value");
266 gauge_u64!(spin.test_gauge_u64 = 1, attr = "value");
267 gauge_i64!(spin.test_gauge_i64 = -1, attr = "value");
268 gauge_f64!(spin.test_gauge_f64 = 1.5, attr = "value");
269 up_and_down_counter!(spin.test_up_and_down_counter = -1, attr = "value");
270 // repeat to ensure repeat calls still compile
271 up_and_down_counter!(spin.test_up_and_down_counter = -1, attr = "value");
272 }
273}