Skip to main content

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}