dynamic_config/otel.rs
1//! OpenTelemetry, for a program that has already chosen it.
2//!
3//! # What this module is, and what it deliberately is not
4//!
5//! It records into the **global meter** an application has already
6//! configured. It does not build a pipeline, own an exporter, choose a
7//! runtime or install a shutdown hook — those belong to whoever wrote
8//! `main`, and a library that takes them over is a library that has to be
9//! fought.
10//!
11//! Concretely: the `otel` feature pulls `opentelemetry`, the API crate, and
12//! **not** `opentelemetry_sdk` or `opentelemetry-otlp`. If nothing has
13//! installed a meter provider, every instrument here is a no-op and costs an
14//! atomic load — which is exactly what should happen to telemetry nobody
15//! asked to collect.
16//!
17//! Traces are already covered and stay where they are: the `tracing` feature
18//! emits `dynamic_config.reload` and `dynamic_config.fetch` spans, and
19//! `tracing-opentelemetry` in the *application* turns those into OTel spans
20//! along with everything else the program traces. Emitting them twice from
21//! here would produce two parents for the same work.
22//!
23//! # The metrics are the ones that already existed
24//!
25//! Every instrument below is named after a constant in
26//! [`telemetry`](crate::telemetry) — the Prometheus names that are already
27//! API. A dashboard built on the scrape and a dashboard built on OTLP see
28//! the same series, because they are the same series.
29//!
30//! # Cardinality, and what an attribute may not carry
31//!
32//! The rule the Prometheus half follows does not bend here: **no key paths
33//! and no values.** A reload reason is one of five strings, an error kind is
34//! one of ten, a fingerprint is a digest with secrets masked, and a
35//! configuration's name is written by whoever called this. A store's
36//! description is never an attribute — a store URL routinely embeds
37//! `user:password@host`.
38
39use opentelemetry::metrics::{Counter, Meter};
40use opentelemetry::{global, KeyValue};
41
42use crate::reload::{ConfigStatus, ReloadEvent};
43
44/// The attribute naming which configuration a sample is about.
45pub const CONFIG: &str = "dynamic_config.config";
46/// The attribute naming why a reload happened.
47pub const REASON: &str = "dynamic_config.reason";
48/// The attribute naming what kind of failure a refusal was.
49pub const ERROR_KIND: &str = "dynamic_config.error_kind";
50/// The attribute carrying the digest of the installed configuration.
51pub const FINGERPRINT: &str = "dynamic_config.fingerprint";
52
53/// The instruments one configuration records into.
54///
55/// Cheap to build and cheap to hold: an instrument from a provider nobody
56/// installed does nothing at all.
57#[derive(Clone)]
58pub struct Recorder {
59 name: &'static str,
60 installs: Counter<u64>,
61 failures: Counter<u64>,
62}
63
64impl std::fmt::Debug for Recorder {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("Recorder")
67 .field("config", &self.name)
68 .finish_non_exhaustive()
69 }
70}
71
72impl Recorder {
73 /// Instruments for the configuration called `name`, from the global
74 /// meter.
75 ///
76 /// `name` is the label every sample carries, so it is the caller's to
77 /// choose — usually the configuration type's name. It must be bounded:
78 /// a name derived from a request, a tenant or a path is a cardinality
79 /// incident waiting for a busy afternoon.
80 #[must_use]
81 pub fn new(name: &'static str) -> Self {
82 Self::with_meter(name, &global::meter("dynamic-config"))
83 }
84
85 /// [`new`](Self::new), against a meter the caller already has.
86 #[must_use]
87 pub fn with_meter(name: &'static str, meter: &Meter) -> Self {
88 Self {
89 name,
90 installs: meter
91 .u64_counter(crate::telemetry::INSTALLS_TOTAL)
92 .with_description("Snapshots installed since the process started.")
93 .build(),
94 failures: meter
95 .u64_counter(crate::telemetry::RELOAD_FAILURES_TOTAL)
96 .with_description("Reloads that installed nothing.")
97 .build(),
98 }
99 }
100
101 /// Records an install.
102 ///
103 /// The reason and the fingerprint ride along as attributes: the first is
104 /// one of five strings, the second is a digest with secrets masked. Both
105 /// are bounded, and neither can carry a value.
106 pub fn installed<T>(&self, event: &ReloadEvent<T>, fingerprint: Option<&str>) {
107 let mut attributes = vec![
108 KeyValue::new(CONFIG, self.name),
109 KeyValue::new(REASON, event.reason.as_str()),
110 ];
111
112 if let Some(fingerprint) = fingerprint {
113 attributes.push(KeyValue::new(FINGERPRINT, fingerprint.to_owned()));
114 }
115
116 self.installs.add(1, &attributes);
117 }
118
119 /// Records a reload that installed nothing.
120 ///
121 /// The error's *kind* and not its message: a message names the file or
122 /// the key that failed, which is unbounded and, in the case of a parse
123 /// failure, is frequently the line holding the password.
124 pub fn refused(&self, error: &crate::Error) {
125 self.failures.add(
126 1,
127 &[
128 KeyValue::new(CONFIG, self.name),
129 KeyValue::new(ERROR_KIND, error.kind().as_str()),
130 ],
131 );
132 }
133
134 /// The attributes a caller's own instrument should carry to line up with
135 /// these.
136 ///
137 /// For the gauges this module does not own — health, staleness, whatever
138 /// an application already computes — so that a dashboard can join them
139 /// without a translation table.
140 #[must_use]
141 pub fn attributes(&self, status: &ConfigStatus) -> Vec<KeyValue> {
142 let mut attributes = vec![KeyValue::new(CONFIG, self.name)];
143
144 if let Some(reason) = &status.last_reason {
145 attributes.push(KeyValue::new(REASON, reason.as_str()));
146 }
147
148 attributes
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 /// With no provider installed the instruments are inert, which is the
157 /// property that lets this be compiled into a library without asking
158 /// anybody's permission first.
159 #[test]
160 fn recording_without_a_provider_is_harmless() {
161 let recorder = Recorder::new("Test");
162
163 recorder.refused(&crate::Error::remote("the store is down"));
164
165 assert!(format!("{recorder:?}").contains("Test"));
166 }
167
168 /// The one thing a `Debug` here must never do is name a store.
169 #[test]
170 fn the_debug_carries_the_config_name_and_nothing_else() {
171 let recorder = Recorder::new("Billing");
172
173 let rendered = format!("{recorder:?}");
174
175 assert!(rendered.contains("Billing"));
176 assert!(!rendered.contains("http"));
177 }
178}