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/// Every metric family this crate emits, in the order [`Exposition`] writes
214/// them.
215///
216/// **These names are API.** They end up in dashboards and alert rules, so a
217/// rename is a breaking change and belongs in the changelog under
218/// *Changed*.
219#[cfg(feature = "telemetry")]
220pub const METRIC_NAMES: [&str; 6] = [
221    INSTALLS_TOTAL,
222    LAST_SUCCESS_SECONDS,
223    CONSECUTIVE_FAILURES,
224    LAST_FAILURE_SECONDS,
225    LAST_RELOAD_INFO,
226    LAST_FAILURE_INFO,
227];
228
229/// `1` when the store answered the last time it was asked, `0` when it did
230/// not, and **absent** before it has been asked at all.
231#[cfg(feature = "telemetry")]
232pub const REMOTE_UP: &str = "dynamic_config_remote_up";
233/// Documents a remote source has handed over since the process started.
234#[cfg(feature = "telemetry")]
235pub const REMOTE_FETCHES_TOTAL: &str = "dynamic_config_remote_fetches_total";
236/// Seconds since a remote source last handed one over.
237#[cfg(feature = "telemetry")]
238pub const REMOTE_LAST_FETCH_SECONDS: &str = "dynamic_config_remote_last_fetch_seconds";
239/// How long the last *pulled* fetch took, in seconds.
240///
241/// A gauge of one measurement rather than a histogram, and named for it: a
242/// dashboard that met `dynamic_config_remote_fetch_duration_seconds` would
243/// reach for `histogram_quantile`, and there are no buckets here to give it.
244/// Rendering percentiles would mean keeping a reservoir per source, which is
245/// state a library has no business choosing the shape of — an application
246/// that wants them times its own `refresh_remote` call in its own recorder.
247#[cfg(feature = "telemetry")]
248pub const REMOTE_LAST_FETCH_DURATION_SECONDS: &str =
249    "dynamic_config_remote_last_fetch_duration_seconds";
250/// Fetches that returned nothing since one returned a document.
251#[cfg(feature = "telemetry")]
252pub const REMOTE_CONSECUTIVE_FAILURES: &str = "dynamic_config_remote_consecutive_failures";
253/// Always `1`; the [`ErrorKind`](crate::ErrorKind) is the label.
254#[cfg(feature = "telemetry")]
255pub const REMOTE_LAST_FAILURE_INFO: &str = "dynamic_config_remote_last_failure_info";
256
257/// Every metric family a [`RemoteStatus`] renders as, in the order
258/// [`Exposition`] writes them.
259///
260/// **These names are API**, on the same terms as [`METRIC_NAMES`]. Separate
261/// from that array rather than appended to it, because the two describe
262/// different things and a process with no remote source emits only the
263/// first.
264#[cfg(feature = "telemetry")]
265pub const REMOTE_METRIC_NAMES: [&str; 6] = [
266    REMOTE_UP,
267    REMOTE_FETCHES_TOTAL,
268    REMOTE_LAST_FETCH_SECONDS,
269    REMOTE_LAST_FETCH_DURATION_SECONDS,
270    REMOTE_CONSECUTIVE_FAILURES,
271    REMOTE_LAST_FAILURE_INFO,
272];
273
274/// One or more configurations' [`ConfigStatus`], as
275/// Prometheus text.
276///
277/// Built per scrape and thrown away: every sample comes from
278/// [`status`](crate::ConfigCell::status), which is atomic loads and no I/O,
279/// so there is no state here worth keeping between scrapes and nothing to
280/// go stale.
281///
282/// # What it emits
283///
284/// | Name | Type | Extra label | Value |
285/// |---|---|---|---|
286/// | `dynamic_config_installs_total` | counter | | installs since the process started |
287/// | `dynamic_config_last_success_seconds` | gauge | | seconds since the serving snapshot landed |
288/// | `dynamic_config_consecutive_failures` | gauge | | failures since the last install; **zero is healthy** |
289/// | `dynamic_config_last_failure_seconds` | gauge | | seconds since the last failure |
290/// | `dynamic_config_last_reload_info` | gauge | `reason` | `1` |
291/// | `dynamic_config_last_failure_info` | gauge | `kind` | `1` |
292///
293/// The two `_seconds` families and the two failure families are **absent**
294/// rather than zero where the fact does not exist yet: a configuration that
295/// has never been installed has no staleness, and zero would read as
296/// "installed just now", which is the opposite. `last_success_seconds` is
297/// the one an alert is written against — *this service's configuration has
298/// been stale for an hour* is the page that matters, and nothing else here
299/// implies it.
300///
301/// A [`RemoteStatus`] added through [`add_remote`](Self::add_remote) renders
302/// as six more families, and the labels are the caller's in exactly the same
303/// way:
304///
305/// | Name | Type | Extra label | Value |
306/// |---|---|---|---|
307/// | `dynamic_config_remote_up` | gauge | | `1` if the store answered last time, `0` if not |
308/// | `dynamic_config_remote_fetches_total` | counter | | documents the store has handed over |
309/// | `dynamic_config_remote_last_fetch_seconds` | gauge | | seconds since it last handed one over |
310/// | `dynamic_config_remote_last_fetch_duration_seconds` | gauge | | how long the last pull took |
311/// | `dynamic_config_remote_consecutive_failures` | gauge | | fetches that returned nothing since one did |
312/// | `dynamic_config_remote_last_failure_info` | gauge | `kind` | `1` |
313///
314/// `remote_up` is **absent** before the first fetch, on the same principle
315/// as the two `_seconds` families: a source that has been installed and
316/// never asked is not down, and a `0` that means "not yet" is a page nobody
317/// should be woken by.
318///
319/// # Cardinality
320///
321/// Bounded by the caller, and stated so it can be checked. For `C`
322/// configurations and `R` remote sources added — `R ≤ C`, because a
323/// configuration type has one [`Remote`](crate::Remote):
324///
325/// | | per scrape | over a process's life |
326/// |---|---|---|
327/// | a `ConfigStatus` | `6 × C` | `(4 + 5 + 10) × C = 19 × C` |
328/// | a `RemoteStatus` | `6 × R` | `(5 + 10) × R = 15 × R` |
329///
330/// so **at most `6 × C + 6 × R ≤ 12 × C` series in a scrape** and `34 × C`
331/// distinct series in total. The label sets come from fixed enums — five
332/// [reload reasons](crate::ReloadReason) and ten
333/// [error kinds](crate::ErrorKind) — and `C` is the number of
334/// configurations a process has, which is a handful.
335///
336/// A per-*store* series multiplies by a bounded number, which is why it
337/// exists; a per-*key* one would not, which is why there is no method here
338/// that could make one.
339///
340/// **No key path, file name, store key or configured value is ever a
341/// label**, and there is no method here that could make one: labels are the
342/// caller's own, and everything derived from a status is a count, a
343/// duration or a fixed enum's name.
344///
345/// # Example
346///
347/// ```
348/// use dynamic_config::{telemetry::Exposition, ConfigCell};
349///
350/// static PORT: ConfigCell<u16> = ConfigCell::new();
351/// PORT.store(8080);
352///
353/// let mut exposition = Exposition::new();
354/// exposition.add("listener", &PORT.status());
355///
356/// let rendered = exposition.render();
357/// assert!(rendered.contains(r#"dynamic_config_installs_total{config="listener"} 1"#));
358/// ```
359#[cfg(feature = "telemetry")]
360#[cfg_attr(docsrs, doc(cfg(feature = "telemetry")))]
361#[derive(Debug, Clone, Default)]
362pub struct Exposition {
363    entries: Vec<Entry>,
364    remotes: Vec<RemoteEntry>,
365}
366
367#[cfg(feature = "telemetry")]
368#[derive(Debug, Clone)]
369struct Entry {
370    /// The caller's labels, already escaped and joined — `a="b",c="d"` —
371    /// so a family that adds one of its own only has to append.
372    labels: String,
373    status: ConfigStatus,
374}
375
376#[cfg(feature = "telemetry")]
377#[derive(Debug, Clone)]
378struct RemoteEntry {
379    labels: String,
380    status: RemoteStatus,
381}
382
383#[cfg(feature = "telemetry")]
384impl Exposition {
385    /// An empty exposition.
386    #[must_use]
387    pub fn new() -> Self {
388        Self::default()
389    }
390
391    /// Adds one configuration, labelled `config="…"`.
392    ///
393    /// The name is the caller's: a section key, a service name, whatever
394    /// distinguishes this configuration from the others in the process.
395    /// **Not** a path or anything derived from the document.
396    pub fn add(&mut self, config: &str, status: &ConfigStatus) {
397        self.add_with(&[("config", config)], status);
398    }
399
400    /// [`add`](Self::add), with labels of the caller's choosing.
401    ///
402    /// For a process whose configurations need two dimensions rather than
403    /// one — a config server's application and profile, say. Label names
404    /// are sanitised to Prometheus's `[a-zA-Z_][a-zA-Z0-9_]*` and values are
405    /// escaped, so no caller can break out of the exposition; the
406    /// *cardinality* of what is passed stays the caller's own decision, and
407    /// the type's documentation says what a safe one looks like.
408    pub fn add_with(&mut self, labels: &[(&str, &str)], status: &ConfigStatus) {
409        self.entries.push(Entry {
410            labels: render_labels(labels),
411            status: status.clone(),
412        });
413    }
414
415    /// Adds one remote source, labelled `config="…"`.
416    ///
417    /// The same name the configuration's own [`add`](Self::add) was given,
418    /// so the two halves join in a query: `dynamic_config_remote_up` and
419    /// `dynamic_config_last_success_seconds` for one `config` are *the store
420    /// answered* and *the document installed*, which is the pair an
421    /// operator is comparing.
422    ///
423    /// **Not the store's URL**, and there is no overload that takes one: a
424    /// store URL routinely embeds `user:password@host`, so the name a series
425    /// carries is the caller's own — the same rule, and the same reason, as
426    /// for a key path.
427    pub fn add_remote(&mut self, config: &str, status: &RemoteStatus) {
428        self.add_remote_with(&[("config", config)], status);
429    }
430
431    /// [`add_remote`](Self::add_remote), with labels of the caller's
432    /// choosing — as [`add_with`](Self::add_with) is to [`add`](Self::add).
433    pub fn add_remote_with(&mut self, labels: &[(&str, &str)], status: &RemoteStatus) {
434        self.remotes.push(RemoteEntry {
435            labels: render_labels(labels),
436            status: status.clone(),
437        });
438    }
439
440    /// The exposition, as a Prometheus text body.
441    ///
442    /// The durations are measured here rather than at
443    /// [`add`](Self::add), so they are as fresh as the response is.
444    #[must_use]
445    pub fn render(&self) -> String {
446        let mut out = String::new();
447
448        self.family(
449            &mut out,
450            INSTALLS_TOTAL,
451            "counter",
452            "Configuration snapshots installed since the process started.",
453            |entry| Some((String::new(), entry.status.generation.to_string())),
454        );
455        self.family(
456            &mut out,
457            LAST_SUCCESS_SECONDS,
458            "gauge",
459            "Seconds since the serving configuration snapshot was installed.",
460            |entry| {
461                entry
462                    .status
463                    .stale_for()
464                    .map(|elapsed| (String::new(), seconds(elapsed)))
465            },
466        );
467        self.family(
468            &mut out,
469            CONSECUTIVE_FAILURES,
470            "gauge",
471            "Reloads that installed nothing since one did; zero is healthy.",
472            |entry| Some((String::new(), entry.status.consecutive_failures.to_string())),
473        );
474        self.family(
475            &mut out,
476            LAST_FAILURE_SECONDS,
477            "gauge",
478            "Seconds since the last reload that installed nothing.",
479            |entry| {
480                entry
481                    .status
482                    .last_failure
483                    .as_ref()
484                    .map(|failure| (String::new(), seconds(failure.at.elapsed())))
485            },
486        );
487        self.family(
488            &mut out,
489            LAST_RELOAD_INFO,
490            "gauge",
491            "Why the serving snapshot was installed; always 1.",
492            |entry| {
493                entry
494                    .status
495                    .last_reason
496                    .as_ref()
497                    .map(|reason| (format!("reason=\"{}\"", reason.as_str()), "1".to_owned()))
498            },
499        );
500        self.family(
501            &mut out,
502            LAST_FAILURE_INFO,
503            "gauge",
504            "The category of the last reload that installed nothing; always 1.",
505            |entry| {
506                entry.status.last_failure.as_ref().map(|failure| {
507                    (
508                        format!("kind=\"{}\"", failure.kind.as_str()),
509                        "1".to_owned(),
510                    )
511                })
512            },
513        );
514
515        self.remote_families(&mut out);
516
517        out
518    }
519
520    /// The six families a [`RemoteStatus`] renders as.
521    ///
522    /// After the reload families rather than interleaved with them: the text
523    /// format wants a family's samples together, and a reader scanning a
524    /// scrape wants the two questions in two blocks.
525    fn remote_families(&self, out: &mut String) {
526        remote_family(
527            out,
528            &self.remotes,
529            REMOTE_UP,
530            "gauge",
531            "Whether the remote store answered the last time it was asked; \
532             absent until it has been.",
533            |status| {
534                status
535                    .reachable()
536                    .map(|up| (String::new(), u8::from(up).to_string()))
537            },
538        );
539        remote_family(
540            out,
541            &self.remotes,
542            REMOTE_FETCHES_TOTAL,
543            "counter",
544            "Documents a remote store has handed over since the process started.",
545            |status| Some((String::new(), status.fetches.to_string())),
546        );
547        remote_family(
548            out,
549            &self.remotes,
550            REMOTE_LAST_FETCH_SECONDS,
551            "gauge",
552            "Seconds since a remote store last handed a document over.",
553            |status| {
554                status
555                    .stale_for()
556                    .map(|elapsed| (String::new(), seconds(elapsed)))
557            },
558        );
559        remote_family(
560            out,
561            &self.remotes,
562            REMOTE_LAST_FETCH_DURATION_SECONDS,
563            "gauge",
564            "How long the last pulled fetch took, in seconds.",
565            |status| {
566                status
567                    .last_fetch_duration
568                    .map(|elapsed| (String::new(), seconds(elapsed)))
569            },
570        );
571        remote_family(
572            out,
573            &self.remotes,
574            REMOTE_CONSECUTIVE_FAILURES,
575            "gauge",
576            "Fetches that returned nothing since one returned a document; zero is healthy.",
577            |status| Some((String::new(), status.consecutive_failures.to_string())),
578        );
579        remote_family(
580            out,
581            &self.remotes,
582            REMOTE_LAST_FAILURE_INFO,
583            "gauge",
584            "The category of the last fetch that returned nothing; always 1.",
585            |status| {
586                status.last_failure.as_ref().map(|failure| {
587                    (
588                        format!("kind=\"{}\"", failure.kind.as_str()),
589                        "1".to_owned(),
590                    )
591                })
592            },
593        );
594    }
595
596    /// One metric family: the header, then a sample per entry that has one.
597    ///
598    /// Samples of a family are written together, which the text format
599    /// requires — hence one pass per family rather than one per entry.
600    fn family(
601        &self,
602        out: &mut String,
603        name: &str,
604        kind: &str,
605        help: &str,
606        sample: impl Fn(&Entry) -> Option<(String, String)>,
607    ) {
608        let samples: Vec<_> = self
609            .entries
610            .iter()
611            .filter_map(|entry| {
612                sample(entry).map(|(extra, value)| (join(&entry.labels, &extra), value))
613            })
614            .collect();
615
616        write_family(out, name, kind, help, &samples);
617    }
618}
619
620/// [`Exposition::family`], over the remote entries.
621#[cfg(feature = "telemetry")]
622fn remote_family(
623    out: &mut String,
624    entries: &[RemoteEntry],
625    name: &str,
626    kind: &str,
627    help: &str,
628    sample: impl Fn(&RemoteStatus) -> Option<(String, String)>,
629) {
630    let samples: Vec<_> = entries
631        .iter()
632        .filter_map(|entry| {
633            sample(&entry.status).map(|(extra, value)| (join(&entry.labels, &extra), value))
634        })
635        .collect();
636
637    write_family(out, name, kind, help, &samples);
638}
639
640/// A family's header and its samples, or nothing at all.
641///
642/// A family with no samples is omitted entirely — header included — rather
643/// than declared and left empty: a `# TYPE` with nothing under it tells a
644/// reader a metric exists and is broken, when in fact the fact it reports
645/// has not happened yet.
646#[cfg(feature = "telemetry")]
647fn write_family(
648    out: &mut String,
649    name: &str,
650    kind: &str,
651    help: &str,
652    samples: &[(String, String)],
653) {
654    if samples.is_empty() {
655        return;
656    }
657
658    out.push_str("# HELP ");
659    out.push_str(name);
660    out.push(' ');
661    out.push_str(help);
662    out.push_str("\n# TYPE ");
663    out.push_str(name);
664    out.push(' ');
665    out.push_str(kind);
666    out.push('\n');
667
668    for (labels, value) in samples {
669        out.push_str(name);
670
671        if !labels.is_empty() {
672            out.push('{');
673            out.push_str(labels);
674            out.push('}');
675        }
676
677        out.push(' ');
678        out.push_str(value);
679        out.push('\n');
680    }
681}
682
683/// A caller's labels, escaped and joined into `a="b",c="d"` — so a family
684/// that adds one of its own only has to append.
685///
686/// Label names are coerced to Prometheus's `[a-zA-Z_][a-zA-Z0-9_]*` and
687/// values are escaped, so no caller can break out of the exposition.
688#[cfg(feature = "telemetry")]
689fn render_labels(labels: &[(&str, &str)]) -> String {
690    let mut rendered = String::new();
691
692    for (name, value) in labels {
693        if !rendered.is_empty() {
694            rendered.push(',');
695        }
696
697        push_label_name(&mut rendered, name);
698        rendered.push_str("=\"");
699        push_label_value(&mut rendered, value);
700        rendered.push('"');
701    }
702
703    rendered
704}
705
706#[cfg(feature = "telemetry")]
707impl fmt::Display for Exposition {
708    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
709        f.write_str(&self.render())
710    }
711}
712
713/// The caller's labels and a family's own, comma-joined, either side of
714/// which may be empty.
715#[cfg(feature = "telemetry")]
716fn join(left: &str, right: &str) -> String {
717    match (left.is_empty(), right.is_empty()) {
718        (true, _) => right.to_owned(),
719        (_, true) => left.to_owned(),
720        _ => format!("{left},{right}"),
721    }
722}
723
724/// A duration as seconds, with the fractional part a scrape interval can
725/// actually resolve.
726#[cfg(feature = "telemetry")]
727fn seconds(elapsed: std::time::Duration) -> String {
728    format!("{:.3}", elapsed.as_secs_f64())
729}
730
731/// A label name, coerced into `[a-zA-Z_][a-zA-Z0-9_]*`.
732///
733/// Coerced rather than refused: this is an exporter, and a handler that
734/// panics or returns nothing because somebody named a label `pod-name` has
735/// turned a diagnostic into an outage.
736#[cfg(feature = "telemetry")]
737fn push_label_name(out: &mut String, name: &str) {
738    let start = out.len();
739
740    for character in name.chars() {
741        if character.is_ascii_alphanumeric() || character == '_' {
742            out.push(character);
743        } else {
744            out.push('_');
745        }
746    }
747
748    match out[start..].chars().next() {
749        None => out.push('_'),
750        Some(first) if first.is_ascii_digit() => out.insert(start, '_'),
751        Some(_) => {}
752    }
753}
754
755/// A label value, escaped as the text format requires.
756///
757/// The three characters that end a label value early are the three that are
758/// escaped; everything else is UTF-8 and goes through. A caller cannot
759/// forge a sample line by naming a section `x" } 1\n`.
760#[cfg(feature = "telemetry")]
761fn push_label_value(out: &mut String, value: &str) {
762    for character in value.chars() {
763        match character {
764            '\\' => out.push_str(r"\\"),
765            '"' => out.push_str("\\\""),
766            '\n' => out.push_str("\\n"),
767            _ => out.push(character),
768        }
769    }
770}
771
772#[cfg(all(test, feature = "telemetry"))]
773mod tests {
774    use super::*;
775
776    #[test]
777    fn a_label_name_is_coerced_and_a_value_is_escaped() {
778        let mut name = String::new();
779        push_label_name(&mut name, "pod-name");
780        assert_eq!(name, "pod_name");
781
782        let mut leading = String::new();
783        push_label_name(&mut leading, "9lives");
784        assert_eq!(leading, "_9lives");
785
786        let mut empty = String::new();
787        push_label_name(&mut empty, "");
788        assert_eq!(empty, "_");
789
790        let mut value = String::new();
791        push_label_value(&mut value, "x\" } 1\nforged 2");
792        assert_eq!(value, r#"x\" } 1\nforged 2"#);
793    }
794
795    #[test]
796    fn labels_join_from_either_side_or_neither() {
797        assert_eq!(join("a=\"1\"", "b=\"2\""), "a=\"1\",b=\"2\"");
798        assert_eq!(join("", "b=\"2\""), "b=\"2\"");
799        assert_eq!(join("a=\"1\"", ""), "a=\"1\"");
800        assert_eq!(join("", ""), "");
801    }
802}