dynamic_config/telemetry.rs
1//! What a reload says about itself, and the numbers a scrape reads.
2//!
3//! Two halves, behind two features, because they answer to two different
4//! consumers and neither should drag in the other:
5//!
6//! - **`tracing`** — an install and a refusal each emit a structured record
7//! on the reload path, carrying the [reload reason](crate::ReloadReason),
8//! the generation, and (on a refusal) the
9//! [`ErrorKind`](crate::ErrorKind) and key path; a fetch from a remote
10//! store runs in a `dynamic_config.fetch` span with an event inside it
11//! carrying the outcome. There is nothing on the read path: reads are an
12//! atomic load and stay one.
13//! - **`telemetry`** — [`Exposition`], which renders a [`ConfigStatus`] and a
14//! [`RemoteStatus`] as Prometheus text. **No
15//! dependency at all**, which is the whole point.
16//!
17//! # This crate does not pick a metrics ecosystem
18//!
19//! A library that depends on `prometheus` picks a fight with every
20//! application that chose `metrics`, or OpenTelemetry, or nothing. So it
21//! depends on none of them. What it offers instead is the numbers —
22//! [`ConfigStatus`] is a handful of atomic loads and no
23//! I/O, so an exporter may call it per scrape — and one rendering of them,
24//! in a text format that is a wire encoding rather than a crate.
25//!
26//! An application already running `metrics` or an OpenTelemetry SDK reads
27//! `status()` in its own recorder and never touches this module; an
28//! application that wants a `/metrics` handler and nothing else uses
29//! [`Exposition`] and pulls in no exporter at all. Spans reach
30//! OpenTelemetry the way every other crate's do, through
31//! `tracing-opentelemetry`, which is the application's dependency and not
32//! this crate's.
33//!
34//! # Nothing here can carry a value
35//!
36//! A metric label is a diagnostic surface like a log line, and a scrape
37//! endpoint is usually the *least* guarded one a process has. So the same
38//! rule holds, one notch tighter: an event field may name a key path — that
39//! is what makes a failure actionable — and **a metric label may not even do
40//! that**. A path is unbounded cardinality as well as a disclosure, and a
41//! series named after a key is how one badly-labelled counter becomes a
42//! million of them.
43
44#[cfg(feature = "telemetry")]
45use std::fmt;
46
47#[cfg(feature = "telemetry")]
48use crate::reload::ConfigStatus;
49#[cfg(feature = "tracing")]
50use crate::reload::ReloadReason;
51#[cfg(feature = "telemetry")]
52use crate::remote::RemoteStatus;
53
54// ---------------------------------------------------------------------------
55// The `tracing` half: two records on the reload path, and none anywhere else.
56// ---------------------------------------------------------------------------
57
58/// The span an install runs in, plus the event that reports it.
59///
60/// Entered around the reload hooks so that whatever a hook logs is
61/// attributed to the reload that ran it, and dropped when the caller's
62/// guard goes out of scope.
63///
64/// `config` is [`std::any::type_name`] of the configuration type: the only
65/// name a cell has — it is the storage for a *type*, and the section key
66/// belongs to the builder that loaded it. It is a diagnostic string, not an
67/// identifier to match on.
68#[cfg(feature = "tracing")]
69pub(crate) fn installed<T: ?Sized>(
70 reason: &ReloadReason,
71 generation: u64,
72) -> tracing::span::EnteredSpan {
73 let span = tracing::info_span!(
74 target: "dynamic_config",
75 "dynamic_config.reload",
76 config = std::any::type_name::<T>(),
77 // The category, never `FileChanged`'s path: this field is copied
78 // into metric labels by everything that consumes it.
79 reason = reason.as_str(),
80 generation = generation,
81 outcome = "installed",
82 );
83 let entered = span.entered();
84
85 // Inside the span, so a subscriber that prints events prints one line
86 // per install rather than leaving the span to be inferred.
87 tracing::info!(
88 target: "dynamic_config",
89 "a configuration snapshot was installed"
90 );
91
92 entered
93}
94
95/// The event for a reload that installed nothing.
96///
97/// An event rather than a span: nothing follows it that could be its child.
98/// The key path is here and the value is not — the same line the rest of
99/// this crate draws, and `tests/security.rs` is where it is enforced.
100#[cfg(feature = "tracing")]
101pub(crate) fn refused<T: ?Sized>(error: &crate::Error) {
102 tracing::warn!(
103 target: "dynamic_config",
104 config = std::any::type_name::<T>(),
105 outcome = "rejected",
106 error.kind = error.kind().as_str(),
107 // A key path, which a diagnostic may name. Recorded as a string
108 // rather than through `Display`, so a structured subscriber gets a
109 // string field instead of a rendered one.
110 error.path = error.path().as_str(),
111 "a reload installed nothing; the previous snapshot is still serving"
112 );
113}
114
115/// The span a fetch from a remote store runs in.
116///
117/// Opened *before* the round trip rather than after it, because the whole
118/// reason to span a fetch is the duration a subscriber reads off it. The
119/// outcome is filled in when there is one, and an event carrying the same
120/// outcome is emitted inside the span — a `Span::record` is invisible to a
121/// subscriber that only prints events, and the outcome is the field anybody
122/// asserts on.
123///
124/// **There is no field naming the store.** The only string a
125/// [`Remote`](crate::Remote) can produce for itself is
126/// [`describe`](crate::Remote::describe), which is the store's URL, and a
127/// store URL routinely embeds `user:password@host`. What identifies a fetch
128/// is the span it is nested in — the caller's own — and what identifies a
129/// *series* is the label whoever renders the [`Exposition`] chose.
130#[cfg(feature = "tracing")]
131pub(crate) fn fetching() -> tracing::span::EnteredSpan {
132 fetching_span().entered()
133}
134
135/// [`fetching`], unentered — for the async path, where the guard would have
136/// to be held across an await point and `EnteredSpan` is `!Send`.
137#[cfg(all(feature = "tracing", feature = "async"))]
138pub(crate) fn fetching_async() -> tracing::Span {
139 fetching_span()
140}
141
142#[cfg(feature = "tracing")]
143fn fetching_span() -> tracing::Span {
144 tracing::info_span!(
145 target: "dynamic_config",
146 "dynamic_config.fetch",
147 outcome = tracing::field::Empty,
148 )
149}
150
151/// A fetch that came back with a document.
152#[cfg(feature = "tracing")]
153pub(crate) fn fetched(span: &tracing::Span, elapsed: std::time::Duration) {
154 span.record("outcome", "fetched");
155
156 span.in_scope(|| {
157 tracing::info!(
158 target: "dynamic_config",
159 outcome = "fetched",
160 // The duration, not the document and not the store: a number of
161 // milliseconds is the one thing a fetch can say about itself
162 // that is neither a value nor a credential.
163 duration_ms = elapsed.as_secs_f64() * 1000.0,
164 "a remote store answered"
165 );
166 });
167}
168
169/// A fetch that came back with nothing.
170///
171/// `WARN`, and the category only. [`ErrorKind::Remote`](crate::ErrorKind) is
172/// a store that may answer next time and [`Auth`](crate::ErrorKind::Auth) is
173/// one that will not, which is the distinction a reader acts on; the message
174/// is left to the `Error` the caller is handed.
175#[cfg(feature = "tracing")]
176pub(crate) fn fetch_failed(span: &tracing::Span, error: &crate::Error) {
177 span.record("outcome", "failed");
178
179 span.in_scope(|| {
180 tracing::warn!(
181 target: "dynamic_config",
182 outcome = "failed",
183 error.kind = error.kind().as_str(),
184 "a fetch from a remote store returned nothing; the document it \
185 last answered with is still in the slot"
186 );
187 });
188}
189
190// ---------------------------------------------------------------------------
191// The `telemetry` half: Prometheus text over `ConfigStatus`.
192// ---------------------------------------------------------------------------
193
194/// Snapshots installed since the process started.
195#[cfg(feature = "telemetry")]
196pub const INSTALLS_TOTAL: &str = "dynamic_config_installs_total";
197/// Seconds since the serving snapshot was installed.
198#[cfg(feature = "telemetry")]
199pub const LAST_SUCCESS_SECONDS: &str = "dynamic_config_last_success_seconds";
200/// Reloads that have installed nothing since one did.
201#[cfg(feature = "telemetry")]
202pub const CONSECUTIVE_FAILURES: &str = "dynamic_config_consecutive_failures";
203/// Seconds since the last reload that installed nothing.
204#[cfg(feature = "telemetry")]
205pub const LAST_FAILURE_SECONDS: &str = "dynamic_config_last_failure_seconds";
206/// Always `1`; the [reload reason](crate::ReloadReason) is the label.
207#[cfg(feature = "telemetry")]
208pub const LAST_RELOAD_INFO: &str = "dynamic_config_last_reload_info";
209/// Always `1`; the [`ErrorKind`](crate::ErrorKind) is the label.
210#[cfg(feature = "telemetry")]
211pub const LAST_FAILURE_INFO: &str = "dynamic_config_last_failure_info";
212
213/// Reloads that installed nothing, as a counter.
214///
215/// Emitted by the [`otel`](crate::otel) recorder rather than by
216/// [`Exposition`]: the Prometheus half answers the *health* question with
217/// [`CONSECUTIVE_FAILURES`], which is the number an alert fires on, and a
218/// scrape can derive a rate from a gauge no better than from nothing. An
219/// OTel counter is the right shape for the same fact, so both exist and
220/// neither is a rename of the other.
221#[cfg(feature = "telemetry")]
222pub const RELOAD_FAILURES_TOTAL: &str = "dynamic_config_reload_failures_total";
223
224/// Every metric family this crate emits, in the order [`Exposition`] writes
225/// them.
226///
227/// **These names are API.** They end up in dashboards and alert rules, so a
228/// rename is a breaking change and belongs in the changelog under
229/// *Changed*.
230#[cfg(feature = "telemetry")]
231pub const METRIC_NAMES: [&str; 6] = [
232 INSTALLS_TOTAL,
233 LAST_SUCCESS_SECONDS,
234 CONSECUTIVE_FAILURES,
235 LAST_FAILURE_SECONDS,
236 LAST_RELOAD_INFO,
237 LAST_FAILURE_INFO,
238];
239
240/// `1` when the store answered the last time it was asked, `0` when it did
241/// not, and **absent** before it has been asked at all.
242#[cfg(feature = "telemetry")]
243pub const REMOTE_UP: &str = "dynamic_config_remote_up";
244/// Documents a remote source has handed over since the process started.
245#[cfg(feature = "telemetry")]
246pub const REMOTE_FETCHES_TOTAL: &str = "dynamic_config_remote_fetches_total";
247/// Seconds since a remote source last handed one over.
248#[cfg(feature = "telemetry")]
249pub const REMOTE_LAST_FETCH_SECONDS: &str = "dynamic_config_remote_last_fetch_seconds";
250/// How long the last *pulled* fetch took, in seconds.
251///
252/// A gauge of one measurement rather than a histogram, and named for it: a
253/// dashboard that met `dynamic_config_remote_fetch_duration_seconds` would
254/// reach for `histogram_quantile`, and there are no buckets here to give it.
255/// Rendering percentiles would mean keeping a reservoir per source, which is
256/// state a library has no business choosing the shape of — an application
257/// that wants them times its own `refresh_remote` call in its own recorder.
258#[cfg(feature = "telemetry")]
259pub const REMOTE_LAST_FETCH_DURATION_SECONDS: &str =
260 "dynamic_config_remote_last_fetch_duration_seconds";
261/// Fetches that returned nothing since one returned a document.
262#[cfg(feature = "telemetry")]
263pub const REMOTE_CONSECUTIVE_FAILURES: &str = "dynamic_config_remote_consecutive_failures";
264/// Always `1`; the [`ErrorKind`](crate::ErrorKind) is the label.
265#[cfg(feature = "telemetry")]
266pub const REMOTE_LAST_FAILURE_INFO: &str = "dynamic_config_remote_last_failure_info";
267
268/// Every metric family a [`RemoteStatus`] renders as, in the order
269/// [`Exposition`] writes them.
270///
271/// **These names are API**, on the same terms as [`METRIC_NAMES`]. Separate
272/// from that array rather than appended to it, because the two describe
273/// different things and a process with no remote source emits only the
274/// first.
275#[cfg(feature = "telemetry")]
276pub const REMOTE_METRIC_NAMES: [&str; 6] = [
277 REMOTE_UP,
278 REMOTE_FETCHES_TOTAL,
279 REMOTE_LAST_FETCH_SECONDS,
280 REMOTE_LAST_FETCH_DURATION_SECONDS,
281 REMOTE_CONSECUTIVE_FAILURES,
282 REMOTE_LAST_FAILURE_INFO,
283];
284
285/// One or more configurations' [`ConfigStatus`], as
286/// Prometheus text.
287///
288/// Built per scrape and thrown away: every sample comes from
289/// [`status`](crate::ConfigCell::status), which is atomic loads and no I/O,
290/// so there is no state here worth keeping between scrapes and nothing to
291/// go stale.
292///
293/// # What it emits
294///
295/// | Name | Type | Extra label | Value |
296/// |---|---|---|---|
297/// | `dynamic_config_installs_total` | counter | | installs since the process started |
298/// | `dynamic_config_last_success_seconds` | gauge | | seconds since the serving snapshot landed |
299/// | `dynamic_config_consecutive_failures` | gauge | | failures since the last install; **zero is healthy** |
300/// | `dynamic_config_last_failure_seconds` | gauge | | seconds since the last failure |
301/// | `dynamic_config_last_reload_info` | gauge | `reason` | `1` |
302/// | `dynamic_config_last_failure_info` | gauge | `kind` | `1` |
303///
304/// The two `_seconds` families and the two failure families are **absent**
305/// rather than zero where the fact does not exist yet: a configuration that
306/// has never been installed has no staleness, and zero would read as
307/// "installed just now", which is the opposite. `last_success_seconds` is
308/// the one an alert is written against — *this service's configuration has
309/// been stale for an hour* is the page that matters, and nothing else here
310/// implies it.
311///
312/// A [`RemoteStatus`] added through [`add_remote`](Self::add_remote) renders
313/// as six more families, and the labels are the caller's in exactly the same
314/// way:
315///
316/// | Name | Type | Extra label | Value |
317/// |---|---|---|---|
318/// | `dynamic_config_remote_up` | gauge | | `1` if the store answered last time, `0` if not |
319/// | `dynamic_config_remote_fetches_total` | counter | | documents the store has handed over |
320/// | `dynamic_config_remote_last_fetch_seconds` | gauge | | seconds since it last handed one over |
321/// | `dynamic_config_remote_last_fetch_duration_seconds` | gauge | | how long the last pull took |
322/// | `dynamic_config_remote_consecutive_failures` | gauge | | fetches that returned nothing since one did |
323/// | `dynamic_config_remote_last_failure_info` | gauge | `kind` | `1` |
324///
325/// `remote_up` is **absent** before the first fetch, on the same principle
326/// as the two `_seconds` families: a source that has been installed and
327/// never asked is not down, and a `0` that means "not yet" is a page nobody
328/// should be woken by.
329///
330/// # Cardinality
331///
332/// Bounded by the caller, and stated so it can be checked. For `C`
333/// configurations and `R` remote sources added — `R ≤ C`, because a
334/// configuration type has one [`Remote`](crate::Remote):
335///
336/// | | per scrape | over a process's life |
337/// |---|---|---|
338/// | a `ConfigStatus` | `6 × C` | `(4 + 5 + 10) × C = 19 × C` |
339/// | a `RemoteStatus` | `6 × R` | `(5 + 10) × R = 15 × R` |
340///
341/// so **at most `6 × C + 6 × R ≤ 12 × C` series in a scrape** and `34 × C`
342/// distinct series in total. The label sets come from fixed enums — five
343/// [reload reasons](crate::ReloadReason) and ten
344/// [error kinds](crate::ErrorKind) — and `C` is the number of
345/// configurations a process has, which is a handful.
346///
347/// A per-*store* series multiplies by a bounded number, which is why it
348/// exists; a per-*key* one would not, which is why there is no method here
349/// that could make one.
350///
351/// **No key path, file name, store key or configured value is ever a
352/// label**, and there is no method here that could make one: labels are the
353/// caller's own, and everything derived from a status is a count, a
354/// duration or a fixed enum's name.
355///
356/// # Example
357///
358/// ```
359/// use dynamic_config::{telemetry::Exposition, ConfigCell};
360///
361/// static PORT: ConfigCell<u16> = ConfigCell::new();
362/// PORT.store(8080);
363///
364/// let mut exposition = Exposition::new();
365/// exposition.add("listener", &PORT.status());
366///
367/// let rendered = exposition.render();
368/// assert!(rendered.contains(r#"dynamic_config_installs_total{config="listener"} 1"#));
369/// ```
370#[cfg(feature = "telemetry")]
371#[cfg_attr(docsrs, doc(cfg(feature = "telemetry")))]
372#[derive(Debug, Clone, Default)]
373pub struct Exposition {
374 entries: Vec<Entry>,
375 remotes: Vec<RemoteEntry>,
376}
377
378#[cfg(feature = "telemetry")]
379#[derive(Debug, Clone)]
380struct Entry {
381 /// The caller's labels, already escaped and joined — `a="b",c="d"` —
382 /// so a family that adds one of its own only has to append.
383 labels: String,
384 status: ConfigStatus,
385}
386
387#[cfg(feature = "telemetry")]
388#[derive(Debug, Clone)]
389struct RemoteEntry {
390 labels: String,
391 status: RemoteStatus,
392}
393
394#[cfg(feature = "telemetry")]
395impl Exposition {
396 /// An empty exposition.
397 #[must_use]
398 pub fn new() -> Self {
399 Self::default()
400 }
401
402 /// Adds one configuration, labelled `config="…"`.
403 ///
404 /// The name is the caller's: a section key, a service name, whatever
405 /// distinguishes this configuration from the others in the process.
406 /// **Not** a path or anything derived from the document.
407 pub fn add(&mut self, config: &str, status: &ConfigStatus) {
408 self.add_with(&[("config", config)], status);
409 }
410
411 /// [`add`](Self::add), with labels of the caller's choosing.
412 ///
413 /// For a process whose configurations need two dimensions rather than
414 /// one — a config server's application and profile, say. Label names
415 /// are sanitised to Prometheus's `[a-zA-Z_][a-zA-Z0-9_]*` and values are
416 /// escaped, so no caller can break out of the exposition; the
417 /// *cardinality* of what is passed stays the caller's own decision, and
418 /// the type's documentation says what a safe one looks like.
419 pub fn add_with(&mut self, labels: &[(&str, &str)], status: &ConfigStatus) {
420 self.entries.push(Entry {
421 labels: render_labels(labels),
422 status: status.clone(),
423 });
424 }
425
426 /// Adds one remote source, labelled `config="…"`.
427 ///
428 /// The same name the configuration's own [`add`](Self::add) was given,
429 /// so the two halves join in a query: `dynamic_config_remote_up` and
430 /// `dynamic_config_last_success_seconds` for one `config` are *the store
431 /// answered* and *the document installed*, which is the pair an
432 /// operator is comparing.
433 ///
434 /// **Not the store's URL**, and there is no overload that takes one: a
435 /// store URL routinely embeds `user:password@host`, so the name a series
436 /// carries is the caller's own — the same rule, and the same reason, as
437 /// for a key path.
438 pub fn add_remote(&mut self, config: &str, status: &RemoteStatus) {
439 self.add_remote_with(&[("config", config)], status);
440 }
441
442 /// [`add_remote`](Self::add_remote), with labels of the caller's
443 /// choosing — as [`add_with`](Self::add_with) is to [`add`](Self::add).
444 pub fn add_remote_with(&mut self, labels: &[(&str, &str)], status: &RemoteStatus) {
445 self.remotes.push(RemoteEntry {
446 labels: render_labels(labels),
447 status: status.clone(),
448 });
449 }
450
451 /// The exposition, as a Prometheus text body.
452 ///
453 /// The durations are measured here rather than at
454 /// [`add`](Self::add), so they are as fresh as the response is.
455 #[must_use]
456 pub fn render(&self) -> String {
457 let mut out = String::new();
458
459 self.family(
460 &mut out,
461 INSTALLS_TOTAL,
462 "counter",
463 "Configuration snapshots installed since the process started.",
464 |entry| Some((String::new(), entry.status.generation.to_string())),
465 );
466 self.family(
467 &mut out,
468 LAST_SUCCESS_SECONDS,
469 "gauge",
470 "Seconds since the serving configuration snapshot was installed.",
471 |entry| {
472 entry
473 .status
474 .stale_for()
475 .map(|elapsed| (String::new(), seconds(elapsed)))
476 },
477 );
478 self.family(
479 &mut out,
480 CONSECUTIVE_FAILURES,
481 "gauge",
482 "Reloads that installed nothing since one did; zero is healthy.",
483 |entry| Some((String::new(), entry.status.consecutive_failures.to_string())),
484 );
485 self.family(
486 &mut out,
487 LAST_FAILURE_SECONDS,
488 "gauge",
489 "Seconds since the last reload that installed nothing.",
490 |entry| {
491 entry
492 .status
493 .last_failure
494 .as_ref()
495 .map(|failure| (String::new(), seconds(failure.at.elapsed())))
496 },
497 );
498 self.family(
499 &mut out,
500 LAST_RELOAD_INFO,
501 "gauge",
502 "Why the serving snapshot was installed; always 1.",
503 |entry| {
504 entry
505 .status
506 .last_reason
507 .as_ref()
508 .map(|reason| (format!("reason=\"{}\"", reason.as_str()), "1".to_owned()))
509 },
510 );
511 self.family(
512 &mut out,
513 LAST_FAILURE_INFO,
514 "gauge",
515 "The category of the last reload that installed nothing; always 1.",
516 |entry| {
517 entry.status.last_failure.as_ref().map(|failure| {
518 (
519 format!("kind=\"{}\"", failure.kind.as_str()),
520 "1".to_owned(),
521 )
522 })
523 },
524 );
525
526 self.remote_families(&mut out);
527
528 out
529 }
530
531 /// The six families a [`RemoteStatus`] renders as.
532 ///
533 /// After the reload families rather than interleaved with them: the text
534 /// format wants a family's samples together, and a reader scanning a
535 /// scrape wants the two questions in two blocks.
536 fn remote_families(&self, out: &mut String) {
537 remote_family(
538 out,
539 &self.remotes,
540 REMOTE_UP,
541 "gauge",
542 "Whether the remote store answered the last time it was asked; \
543 absent until it has been.",
544 |status| {
545 status
546 .reachable()
547 .map(|up| (String::new(), u8::from(up).to_string()))
548 },
549 );
550 remote_family(
551 out,
552 &self.remotes,
553 REMOTE_FETCHES_TOTAL,
554 "counter",
555 "Documents a remote store has handed over since the process started.",
556 |status| Some((String::new(), status.fetches.to_string())),
557 );
558 remote_family(
559 out,
560 &self.remotes,
561 REMOTE_LAST_FETCH_SECONDS,
562 "gauge",
563 "Seconds since a remote store last handed a document over.",
564 |status| {
565 status
566 .stale_for()
567 .map(|elapsed| (String::new(), seconds(elapsed)))
568 },
569 );
570 remote_family(
571 out,
572 &self.remotes,
573 REMOTE_LAST_FETCH_DURATION_SECONDS,
574 "gauge",
575 "How long the last pulled fetch took, in seconds.",
576 |status| {
577 status
578 .last_fetch_duration
579 .map(|elapsed| (String::new(), seconds(elapsed)))
580 },
581 );
582 remote_family(
583 out,
584 &self.remotes,
585 REMOTE_CONSECUTIVE_FAILURES,
586 "gauge",
587 "Fetches that returned nothing since one returned a document; zero is healthy.",
588 |status| Some((String::new(), status.consecutive_failures.to_string())),
589 );
590 remote_family(
591 out,
592 &self.remotes,
593 REMOTE_LAST_FAILURE_INFO,
594 "gauge",
595 "The category of the last fetch that returned nothing; always 1.",
596 |status| {
597 status.last_failure.as_ref().map(|failure| {
598 (
599 format!("kind=\"{}\"", failure.kind.as_str()),
600 "1".to_owned(),
601 )
602 })
603 },
604 );
605 }
606
607 /// One metric family: the header, then a sample per entry that has one.
608 ///
609 /// Samples of a family are written together, which the text format
610 /// requires — hence one pass per family rather than one per entry.
611 fn family(
612 &self,
613 out: &mut String,
614 name: &str,
615 kind: &str,
616 help: &str,
617 sample: impl Fn(&Entry) -> Option<(String, String)>,
618 ) {
619 let samples: Vec<_> = self
620 .entries
621 .iter()
622 .filter_map(|entry| {
623 sample(entry).map(|(extra, value)| (join(&entry.labels, &extra), value))
624 })
625 .collect();
626
627 write_family(out, name, kind, help, &samples);
628 }
629}
630
631/// [`Exposition::family`], over the remote entries.
632#[cfg(feature = "telemetry")]
633fn remote_family(
634 out: &mut String,
635 entries: &[RemoteEntry],
636 name: &str,
637 kind: &str,
638 help: &str,
639 sample: impl Fn(&RemoteStatus) -> Option<(String, String)>,
640) {
641 let samples: Vec<_> = entries
642 .iter()
643 .filter_map(|entry| {
644 sample(&entry.status).map(|(extra, value)| (join(&entry.labels, &extra), value))
645 })
646 .collect();
647
648 write_family(out, name, kind, help, &samples);
649}
650
651/// A family's header and its samples, or nothing at all.
652///
653/// A family with no samples is omitted entirely — header included — rather
654/// than declared and left empty: a `# TYPE` with nothing under it tells a
655/// reader a metric exists and is broken, when in fact the fact it reports
656/// has not happened yet.
657#[cfg(feature = "telemetry")]
658fn write_family(
659 out: &mut String,
660 name: &str,
661 kind: &str,
662 help: &str,
663 samples: &[(String, String)],
664) {
665 if samples.is_empty() {
666 return;
667 }
668
669 out.push_str("# HELP ");
670 out.push_str(name);
671 out.push(' ');
672 out.push_str(help);
673 out.push_str("\n# TYPE ");
674 out.push_str(name);
675 out.push(' ');
676 out.push_str(kind);
677 out.push('\n');
678
679 for (labels, value) in samples {
680 out.push_str(name);
681
682 if !labels.is_empty() {
683 out.push('{');
684 out.push_str(labels);
685 out.push('}');
686 }
687
688 out.push(' ');
689 out.push_str(value);
690 out.push('\n');
691 }
692}
693
694/// A caller's labels, escaped and joined into `a="b",c="d"` — so a family
695/// that adds one of its own only has to append.
696///
697/// Label names are coerced to Prometheus's `[a-zA-Z_][a-zA-Z0-9_]*` and
698/// values are escaped, so no caller can break out of the exposition.
699#[cfg(feature = "telemetry")]
700fn render_labels(labels: &[(&str, &str)]) -> String {
701 let mut rendered = String::new();
702
703 for (name, value) in labels {
704 if !rendered.is_empty() {
705 rendered.push(',');
706 }
707
708 push_label_name(&mut rendered, name);
709 rendered.push_str("=\"");
710 push_label_value(&mut rendered, value);
711 rendered.push('"');
712 }
713
714 rendered
715}
716
717#[cfg(feature = "telemetry")]
718impl fmt::Display for Exposition {
719 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
720 f.write_str(&self.render())
721 }
722}
723
724/// The caller's labels and a family's own, comma-joined, either side of
725/// which may be empty.
726#[cfg(feature = "telemetry")]
727fn join(left: &str, right: &str) -> String {
728 match (left.is_empty(), right.is_empty()) {
729 (true, _) => right.to_owned(),
730 (_, true) => left.to_owned(),
731 _ => format!("{left},{right}"),
732 }
733}
734
735/// A duration as seconds, with the fractional part a scrape interval can
736/// actually resolve.
737#[cfg(feature = "telemetry")]
738fn seconds(elapsed: std::time::Duration) -> String {
739 format!("{:.3}", elapsed.as_secs_f64())
740}
741
742/// A label name, coerced into `[a-zA-Z_][a-zA-Z0-9_]*`.
743///
744/// Coerced rather than refused: this is an exporter, and a handler that
745/// panics or returns nothing because somebody named a label `pod-name` has
746/// turned a diagnostic into an outage.
747#[cfg(feature = "telemetry")]
748fn push_label_name(out: &mut String, name: &str) {
749 let start = out.len();
750
751 for character in name.chars() {
752 if character.is_ascii_alphanumeric() || character == '_' {
753 out.push(character);
754 } else {
755 out.push('_');
756 }
757 }
758
759 match out[start..].chars().next() {
760 None => out.push('_'),
761 Some(first) if first.is_ascii_digit() => out.insert(start, '_'),
762 Some(_) => {}
763 }
764}
765
766/// A label value, escaped as the text format requires.
767///
768/// The three characters that end a label value early are the three that are
769/// escaped; everything else is UTF-8 and goes through. A caller cannot
770/// forge a sample line by naming a section `x" } 1\n`.
771#[cfg(feature = "telemetry")]
772fn push_label_value(out: &mut String, value: &str) {
773 for character in value.chars() {
774 match character {
775 '\\' => out.push_str(r"\\"),
776 '"' => out.push_str("\\\""),
777 '\n' => out.push_str("\\n"),
778 _ => out.push(character),
779 }
780 }
781}
782
783#[cfg(all(test, feature = "telemetry"))]
784mod tests {
785 use super::*;
786
787 #[test]
788 fn a_label_name_is_coerced_and_a_value_is_escaped() {
789 let mut name = String::new();
790 push_label_name(&mut name, "pod-name");
791 assert_eq!(name, "pod_name");
792
793 let mut leading = String::new();
794 push_label_name(&mut leading, "9lives");
795 assert_eq!(leading, "_9lives");
796
797 let mut empty = String::new();
798 push_label_name(&mut empty, "");
799 assert_eq!(empty, "_");
800
801 let mut value = String::new();
802 push_label_value(&mut value, "x\" } 1\nforged 2");
803 assert_eq!(value, r#"x\" } 1\nforged 2"#);
804 }
805
806 #[test]
807 fn labels_join_from_either_side_or_neither() {
808 assert_eq!(join("a=\"1\"", "b=\"2\""), "a=\"1\",b=\"2\"");
809 assert_eq!(join("", "b=\"2\""), "b=\"2\"");
810 assert_eq!(join("a=\"1\"", ""), "a=\"1\"");
811 assert_eq!(join("", ""), "");
812 }
813}