beck_rt/
telemetry.rs

1//! Telemetry — and the question of what it is *for*, in an architecture that already has a log.
2//!
3//! # Why this is not the usual answer
4//!
5//! Distributed tracing exists because in a system of services nobody knows what happened. You
6//! reconstruct causality after the fact from correlated, sampled spans, and you accept that the
7//! reconstruction is partial.
8//!
9//! Beck already has something strictly stronger for the part tracing usually covers: a durable
10//! total order of every state transition. `state = fold(f, init, log[..seq])` is not a sample and
11//! not a reconstruction — it is the actual history, and [`crate::replay_to`] will rebuild any state
12//! the system was ever in. Tracing the fold's internal call tree as spans would re-record, lossily
13//! and at cost, what the log records exactly and for free.
14//!
15//! So the division of labour is specific:
16//!
17//! | question | answered by |
18//! |---|---|
19//! | what happened, in what order, and what state did it produce | the log |
20//! | what state was the system in at 14:02 | the log, by replay |
21//! | why did this command produce that event | the log, by replay |
22//! | how long did the fold take | **here** |
23//! | how long did the append wait on Postgres | **here** |
24//! | what was rejected, and never became an event | **here** |
25//! | how many sessions are connected | **here** |
26//! | what the maintained views cost, shared and per session | **here** |
27//! | did the pod get killed mid-batch | **here** |
28//!
29//! Everything in the right column is either wall-clock, resource use, or a *non-event*: something
30//! the log deliberately does not record, because §4.8 requires the fold to be replay-pure and a
31//! fold that recorded its own duration would not replay identically. Telemetry is not a weaker
32//! substitute for the log here; it is the complement of it, and the boundary between them is exactly
33//! the boundary of determinism.
34//!
35//! # Correlation is `seq`, not a trace id
36//!
37//! A random trace id identifies a request. `seq` identifies a *state*: given one, `beck replay
38//! --to <seq>` reproduces the system exactly as it was. So every record that has a sequence number
39//! carries `beck.seq`, and a span in any OTel backend is one command away from a reproducible
40//! debugging session. That is a property this architecture has and a microservice fleet does not.
41//!
42//! # Is OpenTelemetry valid here?
43//!
44//! Yes, for the right column, and this module speaks it: [`Telemetry::otlp_metrics`] and
45//! [`Telemetry::otlp_logs`] produce OTLP/HTTP JSON, which is a first-class encoding in the OTLP
46//! specification — same field names and semantics as the protobuf form, with no `tonic`, no
47//! `prost`, and no code generation. Export is pull-only: the dashboard serves this data from
48//! memory at `/_beck/otlp/metrics` and `/_beck/otlp/logs`, and nothing pushes to a collector
49//! (a push exporter is scheduled, not built — `docs/101` §101.8).
50//!
51//! What Beck should *not* do is adopt OTel's model as its own. Spans belong at the boundaries —
52//! ingress, validate, append, fold, view, patch — and not inside the fold, where the log is the
53//! better instrument.
54//!
55//! # Cost
56//!
57//! Counters and histogram buckets are `AtomicU64`: recording is one relaxed fetch-add and no
58//! allocation, so instrumenting the fold does not perturb what it measures. Histograms are fixed
59//! power-of-two buckets, so the bucket index is a `leading_zeros`. The log ring is bounded and
60//! overwrites oldest-first, so a process that runs for a month does not accumulate.
61
62use std::collections::VecDeque;
63use std::sync::atomic::{AtomicU64, Ordering};
64use std::sync::{Mutex, OnceLock};
65
66use serde_json::{json, Value as J};
67
68/// Buckets covering 1 µs to ~1 s in powers of two, plus an overflow bucket.
69const BUCKETS: usize = 21;
70
71/// A histogram of durations in microseconds.
72///
73/// Power-of-two buckets so that recording is `leading_zeros` and a fetch-add: no locks, no
74/// allocation, and no comparison chain. The bound this trades away is resolution — a value is
75/// placed within a factor of two — which is the right trade for "is the fold suddenly slow", and
76/// the wrong one for a billing meter. Nothing here is a billing meter.
77#[derive(Debug)]
78pub struct Histogram {
79    buckets: [AtomicU64; BUCKETS],
80    count: AtomicU64,
81    sum_us: AtomicU64,
82}
83
84impl Default for Histogram {
85    fn default() -> Self {
86        Histogram {
87            buckets: std::array::from_fn(|_| AtomicU64::new(0)),
88            count: AtomicU64::new(0),
89            sum_us: AtomicU64::new(0),
90        }
91    }
92}
93
94impl Histogram {
95    pub fn record_us(&self, us: u64) {
96        let i = if us == 0 {
97            0
98        } else {
99            (64 - us.leading_zeros() as usize).min(BUCKETS - 1)
100        };
101        self.buckets[i].fetch_add(1, Ordering::Relaxed);
102        self.count.fetch_add(1, Ordering::Relaxed);
103        self.sum_us.fetch_add(us, Ordering::Relaxed);
104    }
105
106    pub fn record(&self, d: std::time::Duration) {
107        self.record_us(d.as_micros() as u64);
108    }
109
110    pub fn count(&self) -> u64 {
111        self.count.load(Ordering::Relaxed)
112    }
113
114    pub fn sum_us(&self) -> u64 {
115        self.sum_us.load(Ordering::Relaxed)
116    }
117
118    pub fn mean_us(&self) -> f64 {
119        let n = self.count();
120        if n == 0 {
121            0.0
122        } else {
123            self.sum_us() as f64 / n as f64
124        }
125    }
126
127    /// The upper bound of each bucket, in microseconds — OTLP's `explicitBounds`.
128    pub fn bounds() -> Vec<f64> {
129        (0..BUCKETS - 1).map(|i| (1u64 << i) as f64).collect()
130    }
131
132    pub fn counts(&self) -> Vec<u64> {
133        self.buckets
134            .iter()
135            .map(|b| b.load(Ordering::Relaxed))
136            .collect()
137    }
138
139    /// The bucket at or below which `q` of the observations fall, as an upper bound in µs.
140    ///
141    /// A bucketed estimate, not an exact quantile: with power-of-two buckets the true value is
142    /// within a factor of two of what this returns, and saying "p99 is at most 4 ms" is the honest
143    /// form of the claim.
144    pub fn quantile_us(&self, q: f64) -> u64 {
145        let total = self.count();
146        if total == 0 {
147            return 0;
148        }
149        let target = (total as f64 * q).ceil() as u64;
150        let mut seen = 0;
151        for (i, b) in self.buckets.iter().enumerate() {
152            seen += b.load(Ordering::Relaxed);
153            if seen >= target {
154                return 1u64 << i;
155            }
156        }
157        1u64 << (BUCKETS - 1)
158    }
159}
160
161#[derive(Debug, Default)]
162pub struct Counter(AtomicU64);
163
164impl Counter {
165    pub fn incr(&self) {
166        self.0.fetch_add(1, Ordering::Relaxed);
167    }
168    pub fn add(&self, n: u64) {
169        self.0.fetch_add(n, Ordering::Relaxed);
170    }
171    /// Adopt a total that is counted somewhere else.
172    ///
173    /// For a counter whose authoritative value lives outside this struct: the shared dataflow
174    /// counts its own releases, and this exports that number rather than counting the same events a
175    /// second time. Still monotone, because the source is — but `incr`/`add` and this must not be
176    /// mixed on one counter, or neither is right.
177    pub fn sync(&self, total: u64) {
178        self.0.store(total, Ordering::Relaxed);
179    }
180    pub fn get(&self) -> u64 {
181        self.0.load(Ordering::Relaxed)
182    }
183}
184
185/// A gauge — a value that goes up and down, like the number of connected sessions.
186#[derive(Debug, Default)]
187pub struct Gauge(AtomicU64);
188
189impl Gauge {
190    pub fn incr(&self) {
191        self.0.fetch_add(1, Ordering::Relaxed);
192    }
193    pub fn decr(&self) {
194        self.0.fetch_sub(1, Ordering::Relaxed);
195    }
196    pub fn set(&self, n: u64) {
197        self.0.store(n, Ordering::Relaxed);
198    }
199    /// Replace one contributor's share: subtract what it held, add what it holds now.
200    ///
201    /// A gauge that aggregates over many things — the entries every connected subscription is
202    /// arranging — cannot be `set` by any one of them, and re-summing them all on every render is
203    /// the scan the number exists to avoid. Saturating, because a contributor that reports its
204    /// departure twice must not wrap the gauge to `u64::MAX`.
205    pub fn adjust(&self, was: u64, now: u64) {
206        if now >= was {
207            self.0.fetch_add(now - was, Ordering::Relaxed);
208        } else {
209            let d = was - now;
210            let _ = self
211                .0
212                .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
213                    Some(v.saturating_sub(d))
214                });
215        }
216    }
217    pub fn get(&self) -> u64 {
218        self.0.load(Ordering::Relaxed)
219    }
220}
221
222/// One log record, kept in memory for the dashboard and exported as an OTLP log record.
223#[derive(Clone, Debug, serde::Serialize)]
224pub struct Record {
225    pub at_unix_nanos: u64,
226    pub level: &'static str,
227    pub target: String,
228    pub message: String,
229    /// The sequence number this record is about, when there is one. This is the correlation key:
230    /// with it, any record points at a state `beck replay` can reproduce exactly.
231    pub seq: Option<u64>,
232}
233
234/// The instruments.
235///
236/// One value, reached through [`telemetry`], because a metric registry that has to be threaded
237/// through every call site gets threaded through some of them.
238#[derive(Debug, Default)]
239pub struct Telemetry {
240    // --- what the log cannot tell you, because it is time ---
241    /// How long one fold step took.
242    pub fold: Histogram,
243    /// How long rendering a view took. The dominant cost until Phase 3 (docs/19 §19.4 item 3).
244    pub view: Histogram,
245    /// How long the diff between two views took.
246    pub diff: Histogram,
247    /// How long an append to the log store took — the substrate's latency, not the program's.
248    pub append: Histogram,
249    /// How long a snapshot took.
250    pub snapshot: Histogram,
251    /// How long a cold replay took, and over how many events.
252    pub replay: Histogram,
253
254    // --- what the log cannot tell you, because it never happened ---
255    /// Proposals rejected by `validate`. Rejections never become events, so nothing in the log
256    /// records that anyone tried.
257    pub rejected: Counter,
258    /// Proposals dropped as duplicates by the idempotency key.
259    pub deduplicated: Counter,
260    /// Appends that failed. If this is non-zero, the log is missing something the program believed.
261    pub append_failures: Counter,
262    /// Snapshots that failed. Recoverable — the log is still the truth — but slower to recover.
263    pub snapshot_failures: Counter,
264    /// Client messages that did not parse.
265    pub bad_messages: Counter,
266    /// Routes taken on a live subscription — a `nav` frame that moved the client somewhere new.
267    ///
268    /// Counted because it is the one interaction whose cost differs between the two modes by
269    /// construction: in Mode A it is a render and a patch on the server, and in Mode B it is a
270    /// message the server answers with nothing at all.
271    pub navigations: Counter,
272    /// Connections refused because the identity did not verify.
273    ///
274    /// Counted separately from `rejected`, which is `validate` refusing a *command*: one is "who
275    /// are you" and the other is "you may not do that", and an operator watching for an attack
276    /// needs to tell them apart.
277    pub unauthenticated: Counter,
278    /// Proposals refused because the actor was over F3's write quota.
279    ///
280    /// A third counter beside `rejected` and `unauthenticated`, for the same reason those two are
281    /// apart: "you may not do that", "who are you" and "not that often" are three different things
282    /// for an operator watching an attack, and one number covering all three tells them nothing.
283    pub throttled: Counter,
284
285    // --- what the log does record, counted here so a rate is available without folding ---
286    pub events_appended: Counter,
287    pub patch_frames: Counter,
288    pub patch_bytes: Counter,
289
290    // --- what is true right now ---
291    pub sessions: Gauge,
292
293    /// Recent log records, oldest evicted first.
294    ring: Mutex<VecDeque<Record>>,
295    /// The `seq` the process has folded to. A gauge in spirit; stored so the dashboard has it
296    /// without touching the app.
297    pub head: Gauge,
298
299    // --- what a fanout costs, which §5.3 names as a metric and nothing exported until now ---
300    /// Arrangement entries held by the **one** shared dataflow: the operators that do not read the
301    /// session, maintained once however many subscribers there are (docs/23).
302    ///
303    /// Entries rather than bytes. Bytes would need `Engine::footprint`, which walks the accumulator
304    /// to charge shared structure to the fold — right for a report, far too expensive to sample on
305    /// a live process. Entries are `O(operators)` to read and they are the number that scales.
306    pub shared_arranged: Gauge,
307    /// Arrangement entries held by connected subscriptions, between them.
308    ///
309    /// This is the one that multiplies by the fanout, and putting the two side by side is the whole
310    /// operational question: `shared_arranged` is paid once, `session_arranged` is paid per
311    /// connection. A program whose second number dwarfs the first has its cut in the wrong place.
312    pub session_arranged: Gauge,
313    /// Versions of change history the shared dataflow is keeping for subscribers that are behind.
314    ///
315    /// Bounded above by the configured retention depth and below by the laggiest connected
316    /// subscriber, so on a healthy fanout it sits at 1 and a rising number means renders are not
317    /// keeping up with events. That makes it a *lag* signal rather than a memory one, which is the
318    /// more useful reading of the same number.
319    pub shared_retained: Gauge,
320    /// How many times the shared dataflow gave up its arrangements because nothing was subscribed.
321    ///
322    /// Each one is a cold start charged to whichever subscriber reconnects first. A process whose
323    /// releases track its connection count is one whose clients are flapping, and is the case for
324    /// turning `AppConfig::retention.release_when_idle` off.
325    pub shared_releases: Counter,
326}
327
328/// How many log records to keep. Bounded so a long-running process does not accumulate; the log
329/// store is the durable record, and this is a window onto the present.
330const RING_CAPACITY: usize = 2_000;
331
332impl Telemetry {
333    pub fn log(&self, level: &'static str, target: &str, message: String, seq: Option<u64>) {
334        let record = Record {
335            at_unix_nanos: now_unix_nanos(),
336            level,
337            target: target.to_string(),
338            message,
339            seq,
340        };
341        let mut ring = self.ring.lock().expect("telemetry ring poisoned");
342        if ring.len() == RING_CAPACITY {
343            ring.pop_front();
344        }
345        ring.push_back(record);
346    }
347
348    /// The most recent records, newest first.
349    pub fn records(&self, limit: usize) -> Vec<Record> {
350        let ring = self.ring.lock().expect("telemetry ring poisoned");
351        ring.iter().rev().take(limit).cloned().collect()
352    }
353
354    /// Everything, as the dashboard's JSON. Not OTLP: this is shaped for a table on a screen.
355    pub fn snapshot(&self) -> J {
356        let hist = |name: &str, h: &Histogram| {
357            json!({
358                "name": name,
359                "count": h.count(),
360                "mean_us": h.mean_us(),
361                "p50_us": h.quantile_us(0.50),
362                "p99_us": h.quantile_us(0.99),
363            })
364        };
365        json!({
366            "counters": {
367                "events_appended": self.events_appended.get(),
368                "rejected": self.rejected.get(),
369                "unauthenticated": self.unauthenticated.get(),
370                "throttled": self.throttled.get(),
371                "deduplicated": self.deduplicated.get(),
372                "append_failures": self.append_failures.get(),
373                "snapshot_failures": self.snapshot_failures.get(),
374                "bad_messages": self.bad_messages.get(),
375                "navigations": self.navigations.get(),
376                "patch_frames": self.patch_frames.get(),
377                "patch_bytes": self.patch_bytes.get(),
378                "shared_releases": self.shared_releases.get(),
379            },
380            "gauges": {
381                "sessions": self.sessions.get(),
382                "head": self.head.get(),
383                "shared_arranged": self.shared_arranged.get(),
384                "session_arranged": self.session_arranged.get(),
385                "shared_retained": self.shared_retained.get(),
386            },
387            "histograms": [
388                hist("fold", &self.fold),
389                hist("view", &self.view),
390                hist("diff", &self.diff),
391                hist("append", &self.append),
392                hist("snapshot", &self.snapshot),
393                hist("replay", &self.replay),
394            ],
395        })
396    }
397
398    /// Every scalar this runtime records: the name, what it means, and whether it only rises.
399    ///
400    /// **One table, read by both exports.** The OTLP JSON and the OpenMetrics text are two
401    /// spellings of the same numbers, and a metric added to one and not the other is the obvious
402    /// way for them to drift. Adding a counter here adds it to both, which is a structure rather
403    /// than a thing to remember.
404    ///
405    /// The names are OTLP's, dotted. [`Telemetry::openmetrics`] transliterates them, because a
406    /// Prometheus name cannot hold a dot.
407    fn scalars(&self) -> Vec<Scalar> {
408        let counter = |name, help, value| Scalar {
409            name,
410            help,
411            monotonic: true,
412            value,
413        };
414        let gauge = |name, help, value| Scalar {
415            name,
416            help,
417            monotonic: false,
418            value,
419        };
420        vec![
421            counter(
422                "beck.events.appended",
423                "events appended to the log",
424                self.events_appended.get(),
425            ),
426            counter(
427                "beck.proposals.rejected",
428                "proposals `validate` refused",
429                self.rejected.get(),
430            ),
431            counter(
432                "beck.connections.unauthenticated",
433                "connections that presented no usable identity",
434                self.unauthenticated.get(),
435            ),
436            counter(
437                "beck.proposals.deduplicated",
438                "proposals already seen, by idempotency key",
439                self.deduplicated.get(),
440            ),
441            counter(
442                "beck.log.append.failures",
443                "appends the store refused",
444                self.append_failures.get(),
445            ),
446            counter(
447                "beck.snapshot.failures",
448                "snapshots the store refused",
449                self.snapshot_failures.get(),
450            ),
451            counter(
452                "beck.messages.malformed",
453                "client messages that did not parse",
454                self.bad_messages.get(),
455            ),
456            counter(
457                "beck.navigations",
458                "route changes a client reported",
459                self.navigations.get(),
460            ),
461            counter(
462                "beck.patch.frames",
463                "patch frames sent to clients",
464                self.patch_frames.get(),
465            ),
466            counter(
467                "beck.patch.bytes",
468                "bytes of patch payload sent to clients",
469                self.patch_bytes.get(),
470            ),
471            counter(
472                "beck.views.shared_releases",
473                "shared arrangements released when their last reader left",
474                self.shared_releases.get(),
475            ),
476            gauge(
477                "beck.sessions.active",
478                "sessions currently connected",
479                self.sessions.get(),
480            ),
481            gauge("beck.log.head", "the log's head `seq`", self.head.get()),
482            gauge(
483                "beck.views.shared_arranged",
484                "arrangements held once for every subscriber",
485                self.shared_arranged.get(),
486            ),
487            gauge(
488                "beck.views.session_arranged",
489                "arrangements held per subscriber",
490                self.session_arranged.get(),
491            ),
492            gauge(
493                "beck.views.shared_retained",
494                "readers holding a shared arrangement",
495                self.shared_retained.get(),
496            ),
497        ]
498    }
499
500    /// Every duration this runtime records. The same table rule as [`Telemetry::scalars`].
501    fn histograms(&self) -> Vec<(&'static str, &Histogram)> {
502        vec![
503            ("beck.fold.duration", &self.fold),
504            ("beck.view.duration", &self.view),
505            ("beck.diff.duration", &self.diff),
506            ("beck.log.append.duration", &self.append),
507            ("beck.snapshot.duration", &self.snapshot),
508            ("beck.replay.duration", &self.replay),
509        ]
510    }
511
512    /// OTLP/HTTP JSON for metrics — the body of a POST to `/v1/metrics`.
513    ///
514    /// Field names and the numeric enums (`aggregationTemporality: 2` is CUMULATIVE) are the
515    /// specification's, so an ordinary collector accepts this without a Beck-specific receiver.
516    pub fn otlp_metrics(&self, service: &str) -> J {
517        // Start first: it is lazily initialised, so reading `now` first would make the very first
518        // export report a start *after* the observation it bounds. A collector is entitled to drop
519        // a cumulative point whose window runs backwards, and it would drop it silently.
520        let start = start_unix_nanos().to_string();
521        let now = now_unix_nanos().to_string();
522
523        let mut metrics: Vec<J> = Vec::new();
524        for m in self.scalars() {
525            metrics.push(if m.monotonic {
526                json!({
527                    "name": m.name,
528                    "unit": "1",
529                    "sum": {
530                        "dataPoints": [{
531                            "asInt": m.value.to_string(),
532                            "startTimeUnixNano": start,
533                            "timeUnixNano": now,
534                        }],
535                        "aggregationTemporality": 2,
536                        "isMonotonic": true,
537                    }
538                })
539            } else {
540                json!({
541                    "name": m.name,
542                    "unit": "1",
543                    "gauge": { "dataPoints": [{ "asInt": m.value.to_string(), "timeUnixNano": now }] }
544                })
545            });
546        }
547        for (name, h) in self.histograms() {
548            metrics.push(json!({
549                "name": name,
550                "unit": "us",
551                "histogram": {
552                    "dataPoints": [{
553                        "count": h.count().to_string(),
554                        "sum": h.sum_us() as f64,
555                        "bucketCounts": h.counts().iter().map(u64::to_string).collect::<Vec<_>>(),
556                        "explicitBounds": Histogram::bounds(),
557                        "startTimeUnixNano": start,
558                        "timeUnixNano": now,
559                    }],
560                    "aggregationTemporality": 2,
561                }
562            }));
563        }
564
565        json!({
566            "resourceMetrics": [{
567                "resource": { "attributes": resource_attributes(service) },
568                "scopeMetrics": [{
569                    "scope": { "name": "beck" },
570                    "metrics": metrics
571                }]
572            }]
573        })
574    }
575
576    /// OpenMetrics 1.0.0 text, for a Prometheus scraper — `docs/12` §12.8's chartered row.
577    ///
578    /// The same numbers [`Telemetry::otlp_metrics`] exports, read off the same tables, in the other
579    /// exposition format the ecosystem speaks. It **adds no measurement**: every value is already
580    /// recorded on the serving path, which is what makes a second export cheap rather than a second
581    /// cost.
582    ///
583    /// Three things the format requires and the JSON does not, so they are decided here:
584    ///
585    /// * **A name is `[a-zA-Z_:][a-zA-Z0-9_:]*`**, so `beck.events.appended` cannot be spelled. The
586    ///   dots become underscores, which is the ecosystem's own transliteration of an OTLP name.
587    /// * **A counter's name ends `_total`.** OpenMetrics requires it; the older Prometheus text
588    ///   format merely prefers it, so satisfying the stricter reader satisfies both.
589    /// * **Durations are seconds.** The histograms hold microseconds and Prometheus's convention is
590    ///   base units, so the values are divided by a million and the names end `_seconds`. The
591    ///   *boundary* is unchanged: a bucket holding observations at or below 1 µs holds the same ones
592    ///   at or below 1e-6 s.
593    ///
594    /// `# UNIT` and `# EOF` are OpenMetrics lines an older 0.0.4 parser reads as comments, so one
595    /// body serves both readers rather than content-negotiating between them.
596    pub fn openmetrics(&self, service: &str) -> String {
597        use std::fmt::Write;
598        let mut out = String::new();
599        // The service every sample is about. The JSON hangs it on a `resource`; a text exposition
600        // has no resource, so it is a label — `job` being the name a scraper already uses for it.
601        let job = escape_label(service);
602
603        for m in self.scalars() {
604            let name = prometheus_name(m.name);
605            let name = if m.monotonic {
606                format!("{name}_total")
607            } else {
608                name
609            };
610            let _ = writeln!(
611                out,
612                "# TYPE {name} {}",
613                if m.monotonic { "counter" } else { "gauge" }
614            );
615            let _ = writeln!(out, "# HELP {name} {}", escape_help(m.help));
616            let _ = writeln!(out, "{name}{{job=\"{job}\"}} {}", m.value);
617        }
618
619        for (dotted, h) in self.histograms() {
620            let name = format!("{}_seconds", prometheus_name(dotted));
621            let _ = writeln!(out, "# TYPE {name} histogram");
622            let _ = writeln!(out, "# UNIT {name} seconds");
623            let _ = writeln!(out, "# HELP {name} how long this took, in seconds");
624            // Cumulative, which is what the format means by a bucket: the stored counts are per
625            // bucket and the last is the overflow, so the running total is the answer and its final
626            // value is the `+Inf` bucket — equal, by construction, to `_count`.
627            let counts = h.counts();
628            let bounds = Histogram::bounds();
629            let mut running = 0u64;
630            for (i, c) in counts.iter().enumerate() {
631                running += c;
632                let le = match bounds.get(i) {
633                    Some(us) => canonical_number(us / 1.0e6),
634                    None => "+Inf".to_string(),
635                };
636                let _ = writeln!(out, "{name}_bucket{{job=\"{job}\",le=\"{le}\"}} {running}");
637            }
638            let _ = writeln!(
639                out,
640                "{name}_sum{{job=\"{job}\"}} {}",
641                canonical_number(h.sum_us() as f64 / 1.0e6)
642            );
643            let _ = writeln!(out, "{name}_count{{job=\"{job}\"}} {}", h.count());
644        }
645
646        // OpenMetrics requires it, and it is the one line that distinguishes a complete body from a
647        // truncated one — which is why the specification has it at all.
648        out.push_str("# EOF\n");
649        out
650    }
651
652    /// OTLP/HTTP JSON for logs — the body of a POST to `/v1/logs`.
653    pub fn otlp_logs(&self, service: &str, limit: usize) -> J {
654        let records: Vec<J> = self
655            .records(limit)
656            .into_iter()
657            .map(|r| {
658                let mut attributes = vec![json!({
659                    "key": "code.namespace",
660                    "value": { "stringValue": r.target }
661                })];
662                // The correlation key. Not a trace id: with this, the exact state is reproducible.
663                if let Some(seq) = r.seq {
664                    attributes.push(json!({
665                        "key": "beck.seq",
666                        "value": { "intValue": seq.to_string() }
667                    }));
668                }
669                json!({
670                    "timeUnixNano": r.at_unix_nanos.to_string(),
671                    "severityNumber": severity_number(r.level),
672                    "severityText": r.level,
673                    "body": { "stringValue": r.message },
674                    "attributes": attributes,
675                })
676            })
677            .collect();
678
679        json!({
680            "resourceLogs": [{
681                "resource": { "attributes": resource_attributes(service) },
682                "scopeLogs": [{ "scope": { "name": "beck" }, "logRecords": records }]
683            }]
684        })
685    }
686}
687
688/// OTel's severity numbers: DEBUG 5, INFO 9, WARN 13, ERROR 17.
689fn severity_number(level: &str) -> u8 {
690    match level {
691        "TRACE" => 1,
692        "DEBUG" => 5,
693        "WARN" => 13,
694        "ERROR" => 17,
695        _ => 9,
696    }
697}
698
699/// One scalar metric, as both exports need it: the name, what it means, and its value.
700///
701/// `monotonic` is the whole distinction between the two kinds — a counter only rises and a gauge
702/// moves either way — and both exports read it rather than being told twice.
703struct Scalar {
704    name: &'static str,
705    help: &'static str,
706    monotonic: bool,
707    value: u64,
708}
709
710/// An OTLP name as a Prometheus one: `beck.log.head` → `beck_log_head`.
711///
712/// A Prometheus metric name is `[a-zA-Z_:][a-zA-Z0-9_:]*`, so a dot cannot appear in one. Replacing
713/// it with an underscore is what the ecosystem's own OTLP-to-Prometheus translation does, which
714/// matters more than it looks: a dashboard written against a collector's output and one written
715/// against this endpoint should name the same series.
716fn prometheus_name(dotted: &str) -> String {
717    dotted
718        .chars()
719        .map(|c| {
720            if c.is_ascii_alphanumeric() || c == '_' || c == ':' {
721                c
722            } else {
723                '_'
724            }
725        })
726        .collect()
727}
728
729/// A float as OpenMetrics' **Canonical Numbers** rule renders it.
730///
731/// The specification does not leave this to the runtime, because an `le` is a *label value* and an
732/// end user reads it: "the target rendering is equivalent to the default Go rendering of float64
733/// values (i.e. `%g`), with a `.0` appended in case there is no decimal point or exponent". Its own
734/// examples pin the two thresholds — `0.0001` and `1e-05`, `100000.0` and `1e+06` — so exponent
735/// form is used exactly when the decimal exponent is below -4 or at least 6, with the exponent
736/// itself signed and at least two digits.
737///
738/// Rust's own two formats are each half of this: `{}` never uses exponent form and `{:e}` always
739/// does, and neither pads the exponent. So `{:e}` supplies the shortest mantissa and the exponent,
740/// and this chooses between them.
741fn canonical_number(x: f64) -> String {
742    // `{:e}` is always `<mantissa>e<exp>`, and the mantissa is the shortest that round-trips —
743    // which is what "default Go rendering" means by its digits.
744    let sci = format!("{x:e}");
745    let (mantissa, exp) = sci.split_once('e').expect("`{:e}` always has an exponent");
746    let exp: i32 = exp.parse().expect("`{:e}` always has an integer exponent");
747    // Below -4 or at least 6, which are the two boundaries the examples above pin.
748    if !(-4..6).contains(&exp) {
749        return format!(
750            "{mantissa}e{}{:02}",
751            if exp < 0 { '-' } else { '+' },
752            exp.abs()
753        );
754    }
755    let decimal = format!("{x}");
756    if decimal.contains('.') {
757        decimal
758    } else {
759        format!("{decimal}.0")
760    }
761}
762
763/// `HELP` text, escaped as the format requires: a backslash and a newline are the two that matter.
764fn escape_help(help: &str) -> String {
765    help.replace('\\', "\\\\").replace('\n', "\\n")
766}
767
768/// A label value, escaped as the format requires — the quote as well, since it closes the value.
769fn escape_label(value: &str) -> String {
770    value
771        .replace('\\', "\\\\")
772        .replace('"', "\\\"")
773        .replace('\n', "\\n")
774}
775
776fn resource_attributes(service: &str) -> J {
777    json!([
778        { "key": "service.name", "value": { "stringValue": service } },
779        { "key": "telemetry.sdk.name", "value": { "stringValue": "beck" } },
780        { "key": "telemetry.sdk.language", "value": { "stringValue": "rust" } },
781    ])
782}
783
784pub fn now_unix_nanos() -> u64 {
785    beck_core::clock::process_clock().now_nanos()
786}
787
788fn start_unix_nanos() -> u64 {
789    static START: OnceLock<u64> = OnceLock::new();
790    *START.get_or_init(now_unix_nanos)
791}
792
793/// The process-wide instruments.
794pub fn telemetry() -> &'static Telemetry {
795    static T: OnceLock<Telemetry> = OnceLock::new();
796    T.get_or_init(|| {
797        // Stamp the start when the instruments come into existence, so a cumulative metric's
798        // window begins when the process began rather than when someone first asked for it.
799        start_unix_nanos();
800        Telemetry::default()
801    })
802}
803
804/// Time a block and record it, returning what the block returned.
805pub fn timed<T>(h: &Histogram, f: impl FnOnce() -> T) -> T {
806    let started = std::time::Instant::now();
807    let out = f();
808    h.record(started.elapsed());
809    out
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    #[test]
817    fn histogram_buckets_are_powers_of_two_and_bound_the_value() {
818        let h = Histogram::default();
819        for us in [0u64, 1, 3, 100, 5_000, 900_000, 90_000_000] {
820            h.record_us(us);
821        }
822        assert_eq!(h.count(), 7);
823        assert_eq!(h.sum_us(), 1 + 3 + 100 + 5_000 + 900_000 + 90_000_000);
824        // Every observation lands in a bucket, including the one that overflows the range.
825        assert_eq!(h.counts().iter().sum::<u64>(), 7);
826        // A bucketed quantile is an upper bound, and must not understate.
827        let h2 = Histogram::default();
828        for _ in 0..99 {
829            h2.record_us(10);
830        }
831        h2.record_us(100_000);
832        assert!(h2.quantile_us(0.50) >= 10, "p50 understated");
833        assert!(h2.quantile_us(0.99) >= 10);
834        assert!(
835            h2.quantile_us(1.0) >= 100_000,
836            "the max must not be understated: {}",
837            h2.quantile_us(1.0)
838        );
839    }
840
841    #[test]
842    fn the_ring_is_bounded_and_keeps_the_newest() {
843        let t = Telemetry::default();
844        for i in 0..RING_CAPACITY + 500 {
845            t.log("INFO", "test", format!("message {i}"), Some(i as u64));
846        }
847        let records = t.records(RING_CAPACITY * 2);
848        assert_eq!(records.len(), RING_CAPACITY, "the ring is not bounded");
849        assert_eq!(
850            records[0].message,
851            format!("message {}", RING_CAPACITY + 499),
852            "records() must be newest first"
853        );
854        assert_eq!(records[0].seq, Some((RING_CAPACITY + 499) as u64));
855    }
856
857    /// `canonical_number` against **the specification's own published values**.
858    ///
859    /// Every number below is copied out of OpenMetrics 1.0.0 — the two "Exposers SHOULD produce
860    /// output for…" lists in *Considerations: Canonical Numbers*, and the `le` values of the
861    /// histogram example that section governs. They are the oracle for the same reason `clbg/`
862    /// rebuilds its constants from the Game's own output: a rendering checked against a rule
863    /// somebody restated is a rendering checked against their reading of it.
864    ///
865    /// The two thresholds are what this is really about. `0.0001` renders as a decimal and `1e-05`
866    /// does not; `100000.0` renders as a decimal and `1e+06` does not. Both boundaries are one
867    /// comparison away from being wrong in a way no round trip could see, because a parser reads
868    /// either spelling perfectly well — it is the *human* comparing two dashboards who cannot.
869    #[test]
870    fn the_le_rendering_is_the_specifications_own() {
871        for (value, want) in [
872            // "the values 0.0 up to 10.0 in 0.001 increments"
873            (0.0, "0.0"),
874            (0.001, "0.001"),
875            (0.002, "0.002"),
876            (0.01, "0.01"),
877            (0.1, "0.1"),
878            (0.9, "0.9"),
879            (0.95, "0.95"),
880            (0.99, "0.99"),
881            (0.999, "0.999"),
882            (1.0, "1.0"),
883            (1.7, "1.7"),
884            (10.0, "10.0"),
885            // "the values 1e-10 up to 1e+10 in powers of ten"
886            (1e-10, "1e-10"),
887            (1e-9, "1e-09"),
888            (1e-5, "1e-05"),
889            (0.0001, "0.0001"),
890            (100000.0, "100000.0"),
891            (1e6, "1e+06"),
892            (1e10, "1e+10"),
893            // …and the wide, deliberately atypical `le` values of the histogram example.
894            (1e23, "1e+23"),
895            (1.1e23, "1.1e+23"),
896        ] {
897            assert_eq!(
898                canonical_number(value),
899                want,
900                "the specification renders {value:?} as `{want}`"
901            );
902        }
903    }
904
905    /// The exposition obeys the rules OpenMetrics states, checked as rules rather than by a parser.
906    ///
907    /// **Nothing in this workspace is a Prometheus scraper**, so this is not a foreign reader
908    /// accepting the body — it is the specification's own MUSTs, encoded, which is the same position
909    /// [`adr/0030`](../../../../../docs/adr/0030-the-webassembly-emitter-writes-its-own-bytes.md) takes
910    /// about a format with no local reader. What it buys over a round trip is that a writer checked
911    /// by its own reader agrees with itself: the rules below are about the *bytes*, and a reader
912    /// written here would have been written to accept whatever these emit.
913    #[test]
914    fn the_exposition_obeys_the_rules_the_specification_states() {
915        let t = Telemetry::default();
916        t.events_appended.add(7);
917        t.sessions.set(2);
918        t.fold.record_us(3);
919        t.fold.record_us(9_000);
920        let body = t.openmetrics("todo");
921
922        // "Expositions MUST end with EOF and SHOULD end with 'EOF\n'."
923        assert!(body.ends_with("# EOF\n"), "{body}");
924        // "Line endings ... MUST NOT contain carriage returns."
925        assert!(!body.contains('\r'));
926
927        let mut types: std::collections::BTreeMap<String, String> = Default::default();
928        let mut units: std::collections::BTreeMap<String, String> = Default::default();
929        let mut samples: Vec<(String, String, String)> = Vec::new();
930        for line in body.lines() {
931            if let Some(rest) = line.strip_prefix("# TYPE ") {
932                let (name, kind) = rest.split_once(' ').expect("`# TYPE <name> <kind>`");
933                assert!(
934                    types.insert(name.to_string(), kind.to_string()).is_none(),
935                    "MUST NOT be more than one of each type of metadata line: {name}"
936                );
937            } else if let Some(rest) = line.strip_prefix("# UNIT ") {
938                let (name, unit) = rest.split_once(' ').expect("`# UNIT <name> <unit>`");
939                // "an underscore and the unit MUST be the suffix of the MetricFamily name"
940                assert!(
941                    name.ends_with(&format!("_{unit}")),
942                    "`{name}` does not end with `_{unit}`"
943                );
944                units.insert(name.to_string(), unit.to_string());
945            } else if line.starts_with("# HELP ") || line == "# EOF" {
946                // Metadata, and the terminator.
947            } else {
948                // "Aside from this metadata and the EOF line ... you MUST NOT expose lines
949                // beginning with a #."
950                assert!(!line.starts_with('#'), "stray comment line: {line}");
951                let (name, value) = line.rsplit_once(' ').expect("`<series> <value>`");
952                let (name, labels) = match name.split_once('{') {
953                    Some((n, l)) => (n, l.trim_end_matches('}')),
954                    None => (name, ""),
955                };
956                // The ABNF for a metric name, and the reason a dot cannot survive transliteration.
957                assert!(
958                    name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_' || c == ':')
959                        && name
960                            .chars()
961                            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':'),
962                    "`{name}` is not a metric name the ABNF admits"
963                );
964                samples.push((name.to_string(), labels.to_string(), value.to_string()));
965            }
966        }
967
968        assert!(!types.is_empty() && !samples.is_empty());
969        // "A counter's Total Value Sample MetricName MUST have the suffix `_total`."
970        for (family, kind) in &types {
971            if kind == "counter" {
972                assert!(family.ends_with("_total"), "counter `{family}`");
973            }
974        }
975        // Every sample belongs to a family that declared its type — the rule that makes the
976        // exposition self-describing rather than a list of numbers.
977        for (name, _, _) in &samples {
978            assert!(
979                types.keys().any(|f| name == f
980                    || ["_bucket", "_sum", "_count"]
981                        .iter()
982                        .any(|s| name == &format!("{f}{s}"))),
983                "`{name}` has no `# TYPE` line"
984            );
985        }
986
987        // "Buckets MUST be sorted in number increasing order of `le`", and "if and only if a Sum
988        // Value is present ... the +Inf Bucket value MUST also appear ... with the suffix `_count`".
989        for (family, kind) in &types {
990            if kind != "histogram" {
991                continue;
992            }
993            let buckets: Vec<(&str, u64)> = samples
994                .iter()
995                .filter(|(n, _, _)| n == &format!("{family}_bucket"))
996                .map(|(_, labels, v)| {
997                    let le = labels
998                        .split(',')
999                        .find_map(|l| l.strip_prefix("le=\""))
1000                        .expect("a bucket has an `le`")
1001                        .trim_end_matches('"');
1002                    (le, v.parse::<u64>().expect("a bucket count"))
1003                })
1004                .collect();
1005            assert_eq!(buckets.last().expect("buckets").0, "+Inf", "{family}");
1006            let mut previous = f64::NEG_INFINITY;
1007            for (le, _) in &buckets {
1008                let n = if *le == "+Inf" {
1009                    f64::INFINITY
1010                } else {
1011                    le.parse().expect("an `le` is a number")
1012                };
1013                assert!(n > previous, "{family}: {le} does not increase");
1014                previous = n;
1015            }
1016            let counts: Vec<u64> = buckets.iter().map(|(_, c)| *c).collect();
1017            assert!(
1018                counts.windows(2).all(|w| w[1] >= w[0]),
1019                "{family}: buckets are cumulative"
1020            );
1021            let total = samples
1022                .iter()
1023                .find(|(n, _, _)| n == &format!("{family}_count"))
1024                .expect("a histogram with a sum has a count")
1025                .2
1026                .parse::<u64>()
1027                .expect("a count");
1028            assert_eq!(
1029                total,
1030                *counts.last().expect("buckets"),
1031                "{family}: `_count` is the `+Inf` bucket"
1032            );
1033        }
1034    }
1035
1036    /// The two exports carry the same numbers, because they read the same table.
1037    ///
1038    /// They share [`Telemetry::scalars`] now, so this cannot drift by one export being edited — but
1039    /// the *transliteration* can, and a name that reaches Prometheus as something else is a
1040    /// dashboard that silently stops matching a collector's.
1041    #[test]
1042    fn both_exports_carry_the_same_scalars() {
1043        let t = Telemetry::default();
1044        t.events_appended.add(7);
1045        t.sessions.set(2);
1046        let json = t.otlp_metrics("todo");
1047        let text = t.openmetrics("todo");
1048
1049        let metrics = json["resourceMetrics"][0]["scopeMetrics"][0]["metrics"]
1050            .as_array()
1051            .expect("metrics")
1052            .clone();
1053        assert!(metrics.len() >= 20, "{}", metrics.len());
1054        for m in &metrics {
1055            let dotted = m["name"].as_str().expect("a name");
1056            let (value, suffix) = if let Some(p) = m.get("sum") {
1057                (
1058                    p["dataPoints"][0]["asInt"].as_str().map(str::to_string),
1059                    "_total",
1060                )
1061            } else if let Some(p) = m.get("gauge") {
1062                (p["dataPoints"][0]["asInt"].as_str().map(str::to_string), "")
1063            } else {
1064                // A histogram: renamed to seconds, and held by the rules test above.
1065                continue;
1066            };
1067            let want = format!(
1068                "{}{suffix}{{job=\"todo\"}} {}",
1069                dotted.replace('.', "_"),
1070                value.expect("an integer point")
1071            );
1072            assert!(
1073                text.lines().any(|l| l == want),
1074                "the JSON has `{dotted}` and the text has no `{want}`"
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn the_metrics_body_is_shaped_like_otlp() {
1081        // Not a schema validation — a guard that the field names stay the ones a collector reads.
1082        // Getting `aggregationTemporality` or `asInt`-as-a-string wrong produces a body that is
1083        // accepted and silently dropped, which is the failure mode worth a test.
1084        let t = Telemetry::default();
1085        t.events_appended.add(7);
1086        t.fold.record_us(1_500);
1087        let body = t.otlp_metrics("todo");
1088
1089        let metrics = &body["resourceMetrics"][0]["scopeMetrics"][0]["metrics"];
1090        let by_name = |n: &str| {
1091            metrics
1092                .as_array()
1093                .unwrap()
1094                .iter()
1095                .find(|m| m["name"] == n)
1096                .unwrap_or_else(|| panic!("no metric {n}"))
1097                .clone()
1098        };
1099
1100        let appended = by_name("beck.events.appended");
1101        assert_eq!(
1102            appended["sum"]["dataPoints"][0]["asInt"], "7",
1103            "int64 fields are JSON strings"
1104        );
1105        assert_eq!(appended["sum"]["aggregationTemporality"], 2, "CUMULATIVE");
1106        assert_eq!(appended["sum"]["isMonotonic"], true);
1107
1108        let fold = by_name("beck.fold.duration");
1109        let point = &fold["histogram"]["dataPoints"][0];
1110        assert_eq!(point["count"], "1");
1111        assert_eq!(point["sum"], 1500.0);
1112        assert_eq!(
1113            point["bucketCounts"].as_array().unwrap().len(),
1114            point["explicitBounds"].as_array().unwrap().len() + 1,
1115            "OTLP requires exactly one more bucket count than bound"
1116        );
1117
1118        assert_eq!(
1119            body["resourceMetrics"][0]["resource"]["attributes"][0]["value"]["stringValue"],
1120            "todo"
1121        );
1122
1123        // A cumulative point's window must not run backwards. It did, on the first export, because
1124        // the start was initialised lazily *after* `now` was read — and the failure mode is a
1125        // collector silently dropping the point.
1126        for m in metrics.as_array().unwrap() {
1127            let point = m
1128                .get("sum")
1129                .or_else(|| m.get("histogram"))
1130                .map(|k| &k["dataPoints"][0]);
1131            if let Some(p) = point {
1132                let start: u64 = p["startTimeUnixNano"].as_str().unwrap().parse().unwrap();
1133                let now: u64 = p["timeUnixNano"].as_str().unwrap().parse().unwrap();
1134                assert!(
1135                    start <= now,
1136                    "{} reports a window ending before it began",
1137                    m["name"]
1138                );
1139            }
1140        }
1141    }
1142
1143    #[test]
1144    fn a_log_record_carries_the_sequence_number_it_is_about() {
1145        // The claim this module rests on: correlation is `seq`, so a record in any backend points
1146        // at a state `beck replay --to` can reproduce.
1147        let t = Telemetry::default();
1148        t.log("ERROR", "beck_rt::app", "append failed".into(), Some(41));
1149        t.log("INFO", "beck_rt::http", "listening".into(), None);
1150
1151        let body = t.otlp_logs("todo", 10);
1152        let records = body["resourceLogs"][0]["scopeLogs"][0]["logRecords"]
1153            .as_array()
1154            .unwrap()
1155            .clone();
1156        assert_eq!(records.len(), 2);
1157
1158        let failure = records
1159            .iter()
1160            .find(|r| r["severityText"] == "ERROR")
1161            .unwrap();
1162        assert_eq!(failure["severityNumber"], 17);
1163        assert_eq!(failure["body"]["stringValue"], "append failed");
1164        let seq = failure["attributes"]
1165            .as_array()
1166            .unwrap()
1167            .iter()
1168            .find(|a| a["key"] == "beck.seq")
1169            .expect("an error about an append must say which one");
1170        assert_eq!(seq["value"]["intValue"], "41");
1171
1172        // …and a record with no sequence number does not invent one.
1173        let listening = records
1174            .iter()
1175            .find(|r| r["severityText"] == "INFO")
1176            .unwrap();
1177        assert!(
1178            !listening["attributes"]
1179                .as_array()
1180                .unwrap()
1181                .iter()
1182                .any(|a| a["key"] == "beck.seq"),
1183            "a record about no particular state must not claim one"
1184        );
1185    }
1186}