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}