Skip to main content

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}