mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary
Relay pods can stall in the `mesh_boot` startup step, and the mesh peer
table grows without limit. One prod pod held 7,793 peer records. Most of
those records belong to pods that no longer exist.
**Cause.** Every boot gets a fresh runtime id, so a restarted pod leaves
its old record behind. Nothing ever removed records, and scuttlebutt
gossip kept spreading the dead ones around the fleet. Each reconcile
pass, including the first one inside `boot_mesh`, then dials every
record one at a time.
This PR makes dead records expire. It also adds the measurements we need
to see the next stall directly.
### Peer-table lifetime (`crates/buzz-relay-mesh/src/membership.rs`)
- A peer record expires 5 minutes after its last heartbeat
(`DEFAULT_PEER_RECORD_TTL`). For comparison, live runtimes refresh every
2s, and the Redis ready registry expires a silent runtime after 45s.
- What happens to expired records:
- They are **rejected on arrival**, so peers that haven't been upgraded
can't re-add them.
- They are **left out** of digests, deltas, and dial candidates.
- They are **pruned** at the start of each reconcile pass.
- Between prunes, they are not admitted (`has_peer`) and not routed
(`peers`, `peer_has_capability`).
- An expired entry that hasn't been pruned yet counts as absent, so any
fresh record replaces it whatever its version. That lets a live peer's
v1 Redis hint re-admit it before the next prune.
- **The TTL never prunes or hides a peer with a live connection**, even
if its heartbeat is stale. Its gossip can stop arriving while the
transport stays up: a dead control stream, or a clock behind by more
than the TTL. The connection proves the runtime is alive. Its stale
record is still kept out of digests and dials. (The existing phi filter
in `peers()` still drops a connected peer once its gossip stalls; this
PR doesn't change that.)
- **`Connected` tracks the transport map.** Because the TTL exemption
trusts that flag, `install_peer` and `remove_peer` write it while
holding the peers lock, so a connection that closes during install
always ends `Disconnected`. A dial's `Connecting`/`Disconnected` writes
are skipped when a connection is already installed, so a failed outbound
dial can't overwrite a live inbound connection. Each reconcile pass also
re-syncs the state from the transport map.
- **Gossip no longer sets connection state.** Before, any gossiped
record was marked `Connected`, even if this pod had never dialed it.
That inflated the `/_mesh` counts. Now a record learned from gossip or
the ready registry enters as `Disconnected`. Only the real connect and
disconnect paths change the state.
- Before building its candidate list, reconcile now skips peers that are
already connected. A pass dials serially, and a dial to a dead runtime
can take about a minute, so `dial_peer` re-reads the record before every
address attempt. It stops once the record has expired or started
draining, or once a connection has landed.
### Instrumentation
- **`mesh_boot` sub-steps** use the existing startup-step gauges:
`mesh_bind`, `mesh_publish`, and `mesh_initial_reconcile`. A pod stuck
in `mesh_boot` now also shows which await it is stuck on.
- **New metrics** (`crates/buzz-relay-mesh/src/telemetry.rs`):
| Metric | Shows |
|---|---|
| `buzz_mesh_dial_total{outcome}` / `buzz_mesh_dial_duration_seconds` |
Cost of each dial |
| `buzz_mesh_reconcile_pass_seconds` | Full pass time |
| `buzz_mesh_reconcile_candidates` | Dials planned for this pass (set
after the registry scan) |
| `buzz_mesh_peer_records{state,draining}` | Table size |
| `buzz_mesh_peer_record_max_age_seconds` | Oldest heartbeat |
| `buzz_mesh_peer_records_expired_total{reason=pruned\|rejected}` |
Expiry activity |
- The dial and pass histograms get buckets up to 600s. The default
`_seconds` buckets stop at 5s, which would hide exactly the stall these
metrics are meant to show.
### CI
The `buzz-relay-mesh` tests and the `buzz-relay` `mesh_boot::tests` did
not run in any CI lane. This PR adds both to `just test-unit`, and adds
the matching stanzas to `scripts/run-tests.sh`.
### What got simpler
- The peer table now has a defined size, about one record per live pod,
where before it grew forever.
- One wrong state write was deleted (gossip marking records
`Connected`).
- Two test suites that silently never ran now run.
### Rollout notes
- Rejecting expired records works one pod at a time. During a rollout,
pods that haven't been upgraded keep gossiping dead records. Upgraded
pods reject them, which shows up as `reason="rejected"` on the expired
counter.
- A pod whose clock runs more than 5 minutes behind has all of its
gossip rejected. It stays reachable through its Redis ready record and
any live connection. A `reason="rejected"` count that keeps climbing
outside a rollout points to that skew.
- Upgraded pods leave expired records out of their digests. Pods that
haven't been upgraded therefore resend the whole ghost set (about 7.8k
records, roughly 1 MB) on every 2s gossip tick, on each old↔new
connection. So during the rollout, `reason="rejected"` will climb by
about the ghost count per tick per connection. This is expected and
stops when the rollout finishes.
### Not in this PR
The boot dial itself is still unbounded: it runs serially, has no
per-dial timeout, and dials gossip-learned records. Bounding it changes
how a pod joins the mesh, so it goes in a separate PR, and these metrics
will measure it.
### Related issue
None found.
### Testing
- New tests:
- `membership::tests`: expired gossip is rejected; prune drops only
expired records; prune keeps a connected peer with a stale heartbeat;
expired unconnected records are neither admitted nor routed before a
prune; a fresh record replaces an expired, unpruned entry whatever its
version; expired records are neither dialed nor gossiped before a prune;
gossip never sets connection state; table stats.
- `runtime::tests::reconcile_pass_publishes_dial_and_peer_table_metrics`
- `runtime::tests::reconcile_pass_prunes_expired_peer_records` (binds
the prune to the reconcile pass)
-
`runtime::tests::dial_skips_candidate_that_expired_after_the_pass_snapshot`
-
`runtime::tests::finishing_dial_does_not_overwrite_a_connection_that_landed`
-
`runtime::tests::connection_closed_during_install_ends_disconnected_and_expires`
-
`runtime::tests::reconcile_pass_resyncs_connection_state_from_transport`
- `mesh_boot::tests::mesh_boot_sub_steps_name_the_step_that_failed`
- Blox at `e06f55a4` (tree `866add1c`): `just fmt-check`, `just clippy`,
`buzz-relay-mesh` nextest 3x, and the `buzz-relay`
`mesh_boot`/`startup_steps`/`metrics` tests all pass. Three of the new
tests fail against a mutant that removes the fix they pin (dial ignores
expiry; dial state writes ignore the transport map; no reconcile
resync).
---------
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
1354 lines
54 KiB
Rust
1354 lines
54 KiB
Rust
//! Prometheus metrics: recorder setup, upkeep task, and HTTP middleware.
|
|
//!
|
|
//! ```text
|
|
//! ┌──────────────────────────────────────────────────────────┐
|
|
//! │ metrics-rs facade (metrics::counter!, histogram!, etc.) │
|
|
//! │ ↓ │
|
|
//! │ PrometheusBuilder → HTTP listener on :9102 │
|
|
//! │ ↓ │
|
|
//! │ GET /metrics → Prometheus text format │
|
|
//! └──────────────────────────────────────────────────────────┘
|
|
//! ```
|
|
//!
|
|
//! Framework metrics (`http_requests_total`, `http_request_latency_ms`) are
|
|
//! recorded by [`track_metrics`] middleware on the app router. Buzz-specific
|
|
//! metrics are recorded inline at their call sites.
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use axum::{
|
|
extract::{MatchedPath, Request},
|
|
middleware::Next,
|
|
response::Response,
|
|
};
|
|
use metrics_exporter_prometheus::{BuildError, Matcher, PrometheusBuilder};
|
|
use metrics_util::MetricKindMask;
|
|
|
|
/// The fixed method label for the WebSocket NIP-42 challenge lifecycle.
|
|
pub(crate) const AUTH_METHOD_NIP42: &str = "nip42";
|
|
|
|
/// Bounded terminal results for one WebSocket authentication attempt.
|
|
///
|
|
/// Keep this enum free of user-controlled or error-string values. Rollout
|
|
/// analysis relies on these labels having a small, stable cardinality.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum AuthOutcome {
|
|
Success,
|
|
Invalid,
|
|
Banned,
|
|
BanCheckError,
|
|
AllowlistCheckError,
|
|
AllowlistDenied,
|
|
RelayMembershipCheckError,
|
|
NotRelayMember,
|
|
/// NIP-FI key pairing mismatch: the NIP-42 key differs from the asserted key.
|
|
PairingMismatch,
|
|
Timeout,
|
|
Disconnect,
|
|
Shutdown,
|
|
}
|
|
|
|
impl AuthOutcome {
|
|
pub(crate) const ALL: [Self; 12] = [
|
|
Self::Success,
|
|
Self::Invalid,
|
|
Self::Banned,
|
|
Self::BanCheckError,
|
|
Self::AllowlistCheckError,
|
|
Self::AllowlistDenied,
|
|
Self::RelayMembershipCheckError,
|
|
Self::NotRelayMember,
|
|
Self::PairingMismatch,
|
|
Self::Timeout,
|
|
Self::Disconnect,
|
|
Self::Shutdown,
|
|
];
|
|
|
|
pub(crate) const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Success => "success",
|
|
Self::Invalid => "invalid",
|
|
Self::Banned => "banned",
|
|
Self::BanCheckError => "ban_check_error",
|
|
Self::AllowlistCheckError => "allowlist_check_error",
|
|
Self::AllowlistDenied => "allowlist_denied",
|
|
Self::RelayMembershipCheckError => "relay_membership_check_error",
|
|
Self::NotRelayMember => "not_relay_member",
|
|
Self::PairingMismatch => "pairing_mismatch",
|
|
Self::Timeout => "timeout",
|
|
Self::Disconnect => "disconnect",
|
|
Self::Shutdown => "shutdown",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bounded state of an AUTH frame received after the issued challenge had
|
|
/// already reached a terminal. These frames are protocol misuse, not recovery
|
|
/// lifecycles, and therefore stay outside rollout-gating attempts/outcomes.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum AuthPostTerminalState {
|
|
Authenticated,
|
|
Failed,
|
|
}
|
|
|
|
impl AuthPostTerminalState {
|
|
pub(crate) const ALL: [Self; 2] = [Self::Authenticated, Self::Failed];
|
|
|
|
pub(crate) const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Authenticated => "authenticated",
|
|
Self::Failed => "failed",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// HTTP latency buckets (milliseconds) — only for `http_request_latency_ms`.
|
|
const LATENCY_BUCKETS_MS: [f64; 11] = [
|
|
5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0,
|
|
];
|
|
|
|
/// Seconds-scale buckets for internal processing histograms (event, search, audit).
|
|
const DURATION_BUCKETS_S: [f64; 10] = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0];
|
|
|
|
/// Readiness buckets concentrate resolution near the two-second failure budget.
|
|
const READINESS_DURATION_BUCKETS_S: [f64; 15] = [
|
|
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5,
|
|
];
|
|
|
|
/// Pool checkout buckets: dense around normal sub-100ms waits, with explicit
|
|
/// coverage of the reader's 150ms and writer's default three-second budgets.
|
|
const DB_POOL_ACQUIRE_DURATION_BUCKETS_S: [f64; 9] =
|
|
[0.001, 0.005, 0.01, 0.025, 0.05, 0.15, 0.5, 1.0, 3.0];
|
|
const DB_POOL_ACQUIRE_DURATION_UNIT: metrics::Unit = metrics::Unit::Seconds;
|
|
|
|
/// Writer pool/session buckets preserve sub-millisecond setup while retaining
|
|
/// seconds-scale failures in the final bucket.
|
|
const DB_CONNECTION_STEP_DURATION_BUCKETS_S: [f64; 10] = [
|
|
0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.15, 0.5, 1.0, 3.0,
|
|
];
|
|
|
|
/// Seconds-scale buckets for Git hydration and pack streams.
|
|
const GIT_DURATION_BUCKETS_S: [f64; 13] = [
|
|
0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0,
|
|
];
|
|
|
|
/// Mesh dial and reconcile-pass buckets. A pass dials peers serially, so a
|
|
/// large or stale peer table can stretch it to minutes; the default
|
|
/// five-second ceiling would hide exactly the stall these exist to show.
|
|
const MESH_DURATION_BUCKETS_S: [f64; 14] = [
|
|
0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, 600.0,
|
|
];
|
|
|
|
/// Byte buckets for hydrated repositories and streamed clone/fetch responses.
|
|
const GIT_BYTES_BUCKETS: [f64; 9] = [
|
|
0.0,
|
|
64.0 * 1024.0,
|
|
1024.0 * 1024.0,
|
|
10.0 * 1024.0 * 1024.0,
|
|
50.0 * 1024.0 * 1024.0,
|
|
100.0 * 1024.0 * 1024.0,
|
|
250.0 * 1024.0 * 1024.0,
|
|
500.0 * 1024.0 * 1024.0,
|
|
1024.0 * 1024.0 * 1024.0,
|
|
];
|
|
|
|
/// Pack-count buckets bounded by the manifest's maximum pack count.
|
|
const GIT_PACK_BUCKETS: [f64; 9] = [0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0];
|
|
|
|
/// Integer-count buckets for fan-out recipient histograms.
|
|
const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, 1000.0];
|
|
|
|
fn configured_prometheus_builder(gauge_idle_timeout_secs: u64) -> PrometheusBuilder {
|
|
PrometheusBuilder::new()
|
|
// Remove gauge series that the relay intentionally stops emitting.
|
|
.idle_timeout(
|
|
MetricKindMask::GAUGE,
|
|
Some(Duration::from_secs(gauge_idle_timeout_secs)),
|
|
)
|
|
// Per-metric buckets: ms for HTTP latency, seconds for internal processing.
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("http_request_latency_ms".to_owned()),
|
|
&LATENCY_BUCKETS_MS,
|
|
)
|
|
.expect("valid ms bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_hydrate_seconds".to_owned()),
|
|
&GIT_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid git hydration duration bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_upload_pack_stream_seconds".to_owned()),
|
|
&GIT_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid git stream duration bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_pack_cache_populate_seconds".to_owned()),
|
|
&GIT_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid git cache population duration bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_pack_cache_population_wait_seconds".to_owned()),
|
|
&GIT_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid git cache population wait bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_pack_compaction_seconds".to_owned()),
|
|
&GIT_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid git compaction duration bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_readiness_check_duration_seconds".to_owned()),
|
|
&READINESS_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid readiness duration bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_db_pool_acquire_duration_seconds".to_owned()),
|
|
&DB_POOL_ACQUIRE_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid DB pool acquisition duration bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_db_connection_step_duration_seconds".to_owned()),
|
|
&DB_CONNECTION_STEP_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid DB connection-step duration bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_hydrate_bytes".to_owned()),
|
|
&GIT_BYTES_BUCKETS,
|
|
)
|
|
.expect("valid git hydration byte bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_upload_pack_stream_bytes".to_owned()),
|
|
&GIT_BYTES_BUCKETS,
|
|
)
|
|
.expect("valid git stream byte bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_pack_compaction_bytes".to_owned()),
|
|
&GIT_BYTES_BUCKETS,
|
|
)
|
|
.expect("valid git compaction byte bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_hydrate_packs".to_owned()),
|
|
&GIT_PACK_BUCKETS,
|
|
)
|
|
.expect("valid git pack-count bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_pack_compaction_packs_before".to_owned()),
|
|
&GIT_PACK_BUCKETS,
|
|
)
|
|
.expect("valid git compaction input pack-count bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_git_pack_compaction_packs_after".to_owned()),
|
|
&GIT_PACK_BUCKETS,
|
|
)
|
|
.expect("valid git compaction output pack-count bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full(buzz_relay_mesh::telemetry::DIAL_DURATION_SECONDS.to_owned()),
|
|
&MESH_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid mesh dial bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full(buzz_relay_mesh::telemetry::RECONCILE_PASS_SECONDS.to_owned()),
|
|
&MESH_DURATION_BUCKETS_S,
|
|
)
|
|
.expect("valid mesh reconcile pass bucket boundaries")
|
|
.set_buckets_for_metric(Matcher::Suffix("_seconds".to_owned()), &DURATION_BUCKETS_S)
|
|
.expect("valid seconds bucket boundaries")
|
|
.set_buckets_for_metric(
|
|
Matcher::Full("buzz_fanout_recipients".to_owned()),
|
|
&FANOUT_BUCKETS,
|
|
)
|
|
.expect("valid fanout bucket boundaries")
|
|
}
|
|
|
|
/// A bounded class of metrics installation failure.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum MetricsInstallFailure {
|
|
/// The Prometheus listener could not bind.
|
|
Bind,
|
|
/// Another component already installed a global recorder.
|
|
RecorderConflict,
|
|
/// The exporter could not be built for another reason.
|
|
ExporterBuild,
|
|
}
|
|
|
|
/// An error returned while installing Prometheus metrics.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum MetricsInstallError {
|
|
/// Prometheus exporter construction failed.
|
|
#[error("failed to build Prometheus exporter: {0}")]
|
|
Build(#[source] BuildError),
|
|
/// Another component already installed the process-global recorder.
|
|
#[error("the global metrics recorder is already installed")]
|
|
RecorderConflict,
|
|
}
|
|
|
|
impl MetricsInstallError {
|
|
/// Return the secret-safe lifecycle classification.
|
|
pub const fn failure(&self) -> MetricsInstallFailure {
|
|
match self {
|
|
Self::Build(BuildError::FailedToCreateHTTPListener(_)) => MetricsInstallFailure::Bind,
|
|
Self::Build(_) => MetricsInstallFailure::ExporterBuild,
|
|
Self::RecorderConflict => MetricsInstallFailure::RecorderConflict,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Try to install the global metrics recorder and spawn the Prometheus HTTP exporter.
|
|
///
|
|
/// `build()` returns the recorder + exporter future and internally spawns
|
|
/// the upkeep task, so no separate upkeep call is needed.
|
|
///
|
|
/// Must be called from within a Tokio runtime.
|
|
/// Listener and global-recorder failures are returned rather than panicking.
|
|
/// A later exporter exit remains detached from relay service; external scrape
|
|
/// coverage is authoritative for exporter availability.
|
|
pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), MetricsInstallError> {
|
|
let (recorder, exporter) = configured_prometheus_builder(gauge_idle_timeout_secs)
|
|
.with_http_listener(([0, 0, 0, 0], port))
|
|
.build()
|
|
.map_err(MetricsInstallError::Build)?;
|
|
|
|
metrics::set_global_recorder(recorder)
|
|
.map_err(|_error| MetricsInstallError::RecorderConflict)?;
|
|
describe_readiness_metrics();
|
|
describe_community_admission_metrics();
|
|
describe_db_pool_metrics();
|
|
describe_auth_metrics();
|
|
crate::startup_steps::describe_metrics();
|
|
buzz_relay_mesh::telemetry::describe_metrics();
|
|
initialize_auth_metric_series();
|
|
initialize_partition_audit_metric_series();
|
|
tokio::spawn(exporter);
|
|
Ok(())
|
|
}
|
|
|
|
/// Install the global metrics recorder and spawn the Prometheus HTTP exporter.
|
|
///
|
|
/// This compatibility entry point preserves the original panic-on-failure API.
|
|
/// New startup code should use [`try_install`] to report typed failures.
|
|
pub fn install(port: u16, gauge_idle_timeout_secs: u64) {
|
|
try_install(port, gauge_idle_timeout_secs)
|
|
.unwrap_or_else(|error| panic!("metrics exporter must install exactly once: {error}"));
|
|
}
|
|
|
|
/// Register the frozen readiness and dependency-diagnostic metric descriptions.
|
|
///
|
|
/// The two `buzz_readiness_*` probe families describe local process lifecycle.
|
|
/// The dependency families keep their names for dashboard continuity but are
|
|
/// published by the per-pod dependency sampler, not by the Kubernetes probe or
|
|
/// by an `/_status` request — a shared-dependency failure no longer deroutes
|
|
/// the pod, and nobody has to read the endpoint for the metrics to move.
|
|
pub(crate) fn describe_readiness_metrics() {
|
|
metrics::describe_counter!(
|
|
"buzz_readiness_checks_total",
|
|
"Kubernetes health-listener readiness probes by lifecycle reason (ready, shutting_down)"
|
|
);
|
|
metrics::describe_counter!(
|
|
"buzz_readiness_dependency_checks_total",
|
|
"Completed dependency-sampler attempts by dependency and bounded outcome"
|
|
);
|
|
metrics::describe_histogram!(
|
|
"buzz_readiness_check_duration_seconds",
|
|
metrics::Unit::Seconds,
|
|
"Completed dependency-sampler check duration without outcome label multiplication"
|
|
);
|
|
metrics::describe_gauge!(
|
|
"buzz_readiness_state",
|
|
"Latest private readiness-probe observation, where 1 is ready and 0 is shutting down"
|
|
);
|
|
metrics::describe_gauge!(
|
|
"buzz_readiness_dependency_sample_completed_timestamp_seconds",
|
|
"Unix time the cached /_status dependency report completed, absent until the first sample completes; sampler completion advances it and the publisher re-emits it"
|
|
);
|
|
}
|
|
|
|
/// Register the bounded community-admission contract.
|
|
pub(crate) fn describe_community_admission_metrics() {
|
|
metrics::describe_counter!(
|
|
"buzz_community_admission_checks_total",
|
|
"Durable community-active checks at socket admission by bounded outcome"
|
|
);
|
|
}
|
|
|
|
/// Register the frozen operation-aware pool-acquisition contract.
|
|
pub(crate) fn describe_db_pool_metrics() {
|
|
metrics::describe_counter!(
|
|
"buzz_db_pool_acquire_started_total",
|
|
"Database pool checkout starts by valid pool role and operation"
|
|
);
|
|
metrics::describe_histogram!(
|
|
"buzz_db_pool_acquire_duration_seconds",
|
|
DB_POOL_ACQUIRE_DURATION_UNIT,
|
|
"Database pool checkout duration by valid pool role and operation"
|
|
);
|
|
metrics::describe_counter!(
|
|
"buzz_db_pool_acquire_attempts_total",
|
|
"Database pool checkout terminals by valid pool role, operation, and outcome"
|
|
);
|
|
metrics::describe_gauge!(
|
|
"buzz_db_pool_waiters",
|
|
"Current tracked-operation database pool checkout attempts in progress by valid pool role and operation"
|
|
);
|
|
metrics::describe_counter!(
|
|
"buzz_db_connection_step_started_total",
|
|
"Writer connection setup phase starts by fixed pool role and step"
|
|
);
|
|
metrics::describe_counter!(
|
|
"buzz_db_connection_step_attempts_total",
|
|
"Writer connection setup terminals by fixed pool role, step, and outcome"
|
|
);
|
|
metrics::describe_histogram!(
|
|
"buzz_db_connection_step_duration_seconds",
|
|
metrics::Unit::Seconds,
|
|
"Writer connection setup phase duration by fixed pool role and step"
|
|
);
|
|
metrics::describe_gauge!(
|
|
"buzz_db_pool_connections",
|
|
"Current Postgres pool connections by physical pool role and bounded utilization state"
|
|
);
|
|
metrics::describe_gauge!(
|
|
"buzz_db_pool_max_connections",
|
|
"Maximum Postgres pool connections by physical pool role"
|
|
);
|
|
metrics::describe_gauge!(
|
|
"buzz_db_pool_configured",
|
|
"Whether a physical Postgres pool role is configured, where 1 is configured and 0 is not configured"
|
|
);
|
|
}
|
|
|
|
/// Named utilization snapshots for every physical Postgres pool role.
|
|
pub struct DbPoolMetricsInput {
|
|
/// Primary writer pool utilization.
|
|
pub writer: buzz_db::DbPoolStats,
|
|
/// Optional read-replica pool utilization.
|
|
pub reader: Option<buzz_db::DbPoolStats>,
|
|
/// Optional audit pool utilization.
|
|
pub audit: Option<buzz_db::DbPoolStats>,
|
|
/// Search pool utilization.
|
|
pub search: buzz_db::DbPoolStats,
|
|
}
|
|
|
|
/// Record fixed-cardinality utilization for every physical Postgres pool role.
|
|
///
|
|
/// Optional roles emit zero-valued utilization when absent so the unified
|
|
/// scrape always contains four roles by two states, four maximum-capacity
|
|
/// series, and four configured series. The original writer and reader gauges
|
|
/// are also emitted unchanged for dashboard compatibility.
|
|
pub fn record_db_pool_metrics(input: DbPoolMetricsInput) {
|
|
let DbPoolMetricsInput {
|
|
writer,
|
|
reader,
|
|
audit,
|
|
search,
|
|
} = input;
|
|
let pools = [Some(writer), reader, audit, Some(search)];
|
|
for (role, stats) in buzz_db::DbPoolRole::ALL.into_iter().zip(pools) {
|
|
let role = role.as_str();
|
|
let configured = stats.is_some();
|
|
let stats = stats.unwrap_or(buzz_db::DbPoolStats {
|
|
size: 0,
|
|
idle: 0,
|
|
max: 0,
|
|
});
|
|
metrics::gauge!("buzz_db_pool_configured", "pool_role" => role).set(if configured {
|
|
1.0
|
|
} else {
|
|
0.0
|
|
});
|
|
for (state, value) in [("idle", stats.idle), ("active", stats.active())] {
|
|
metrics::gauge!(
|
|
"buzz_db_pool_connections",
|
|
"pool_role" => role,
|
|
"state" => state,
|
|
)
|
|
.set(value as f64);
|
|
}
|
|
metrics::gauge!("buzz_db_pool_max_connections", "pool_role" => role).set(stats.max as f64);
|
|
}
|
|
|
|
metrics::gauge!("buzz_db_pool_size").set(writer.size as f64);
|
|
metrics::gauge!("buzz_db_pool_idle").set(writer.idle as f64);
|
|
metrics::gauge!("buzz_db_pool_active").set(writer.active() as f64);
|
|
metrics::gauge!("buzz_db_pool_max").set(writer.max as f64);
|
|
|
|
if let Some(reader) = reader {
|
|
metrics::gauge!("buzz_db_read_pool_size").set(reader.size as f64);
|
|
metrics::gauge!("buzz_db_read_pool_idle").set(reader.idle as f64);
|
|
metrics::gauge!("buzz_db_read_pool_active").set(reader.active() as f64);
|
|
metrics::gauge!("buzz_db_read_pool_max").set(reader.max as f64);
|
|
}
|
|
}
|
|
|
|
/// Register the bounded WebSocket authentication metric contract.
|
|
pub(crate) fn describe_auth_metrics() {
|
|
metrics::describe_counter!(
|
|
"buzz_auth_attempts_total",
|
|
"NIP-42 challenge lifecycles issued to WebSocket clients by bounded method"
|
|
);
|
|
metrics::describe_counter!(
|
|
"buzz_auth_outcomes_total",
|
|
"WebSocket authentication attempts by bounded method and terminal outcome"
|
|
);
|
|
metrics::describe_histogram!(
|
|
"buzz_auth_duration_seconds",
|
|
metrics::Unit::Seconds,
|
|
"WebSocket authentication attempt duration by bounded method and terminal outcome"
|
|
);
|
|
metrics::describe_gauge!(
|
|
"buzz_ws_authenticated_connections_active",
|
|
"Current WebSocket connections that completed authentication on this pod"
|
|
);
|
|
metrics::describe_counter!(
|
|
"buzz_auth_post_terminal_frames_total",
|
|
"AUTH frames received after the issued challenge reached a terminal, by bounded state"
|
|
);
|
|
}
|
|
|
|
/// Publish zero-valued authentication series at process start.
|
|
///
|
|
/// Counters are never idle-evicted by the configured recorder. The active
|
|
/// gauge is refreshed alongside the other lifecycle-relative gauges in
|
|
/// `main.rs`, so a quiet healthy pod remains distinguishable from a missing
|
|
/// scrape target without racing its increments and decrements.
|
|
pub(crate) fn initialize_auth_metric_series() {
|
|
metrics::counter!(
|
|
"buzz_auth_attempts_total",
|
|
"method" => AUTH_METHOD_NIP42
|
|
)
|
|
.increment(0);
|
|
for outcome in AuthOutcome::ALL {
|
|
metrics::counter!(
|
|
"buzz_auth_outcomes_total",
|
|
"method" => AUTH_METHOD_NIP42,
|
|
"outcome" => outcome.as_str()
|
|
)
|
|
.increment(0);
|
|
// Register the fixed-label histogram without recording a fake sample.
|
|
// Prometheus then exports zero buckets, `_sum`, and `_count` at boot.
|
|
let _ = metrics::histogram!(
|
|
"buzz_auth_duration_seconds",
|
|
"method" => AUTH_METHOD_NIP42,
|
|
"outcome" => outcome.as_str()
|
|
);
|
|
}
|
|
for state in AuthPostTerminalState::ALL {
|
|
metrics::counter!(
|
|
"buzz_auth_post_terminal_frames_total",
|
|
"state" => state.as_str()
|
|
)
|
|
.increment(0);
|
|
}
|
|
metrics::gauge!("buzz_ws_authenticated_connections_active").set(0.0);
|
|
}
|
|
|
|
/// Export the partition audit counters at zero before the first audit, so
|
|
/// `increase()`-based alerts count the first failure and every new outcome.
|
|
pub(crate) fn initialize_partition_audit_metric_series() {
|
|
buzz_db::partition::initialize_audit_metric_series();
|
|
metrics::counter!("buzz_partition_audit_failures_total").increment(0);
|
|
}
|
|
|
|
/// Record one issued NIP-42 challenge after it enters the connection writer.
|
|
///
|
|
/// Before this lifecycle contract, `buzz_auth_attempts_total` incremented only
|
|
/// when an AUTH frame reached the pending handler. It now uses one consistent,
|
|
/// recovery-oriented unit: challenges successfully queued to the connection.
|
|
pub(crate) fn record_auth_attempt_started() {
|
|
metrics::counter!(
|
|
"buzz_auth_attempts_total",
|
|
"method" => AUTH_METHOD_NIP42
|
|
)
|
|
.increment(1);
|
|
}
|
|
|
|
/// Record exactly one bounded terminal for an authentication attempt.
|
|
pub(crate) fn record_auth_outcome(outcome: AuthOutcome, duration: Duration) {
|
|
metrics::counter!(
|
|
"buzz_auth_outcomes_total",
|
|
"method" => AUTH_METHOD_NIP42,
|
|
"outcome" => outcome.as_str()
|
|
)
|
|
.increment(1);
|
|
metrics::histogram!(
|
|
"buzz_auth_duration_seconds",
|
|
"method" => AUTH_METHOD_NIP42,
|
|
"outcome" => outcome.as_str()
|
|
)
|
|
.record(duration.as_secs_f64());
|
|
}
|
|
|
|
/// Record protocol noise after the issued challenge already terminalized.
|
|
pub(crate) fn record_post_terminal_auth_frame(state: AuthPostTerminalState) {
|
|
metrics::counter!(
|
|
"buzz_auth_post_terminal_frames_total",
|
|
"state" => state.as_str()
|
|
)
|
|
.increment(1);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn readiness_test_recorder() -> (
|
|
metrics_exporter_prometheus::PrometheusRecorder,
|
|
metrics_exporter_prometheus::PrometheusHandle,
|
|
) {
|
|
readiness_test_recorder_with_idle_timeout(300)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn readiness_test_recorder_with_idle_timeout(
|
|
gauge_idle_timeout_secs: u64,
|
|
) -> (
|
|
metrics_exporter_prometheus::PrometheusRecorder,
|
|
metrics_exporter_prometheus::PrometheusHandle,
|
|
) {
|
|
let recorder = configured_prometheus_builder(gauge_idle_timeout_secs).build_recorder();
|
|
let handle = recorder.handle();
|
|
(recorder, handle)
|
|
}
|
|
|
|
/// Axum middleware that records CAKE framework HTTP metrics.
|
|
///
|
|
/// Emits:
|
|
/// - `http_requests_total{code, caller, action}` — counter
|
|
/// - `http_request_latency_ms{code, caller, action}` — histogram
|
|
///
|
|
/// Skips health/metrics paths (`/_*`, `/health`) to avoid polluting dashboards.
|
|
///
|
|
/// Labels:
|
|
/// - `code`: exact HTTP status code (e.g. "200", "404")
|
|
/// - `caller`: upstream service from Istio `x-envoy-downstream-service-cluster` header
|
|
/// - `action`: matched route pattern (e.g. `/api/channels/{channel_id}`)
|
|
pub async fn track_metrics(req: Request, next: Next) -> Response {
|
|
// Use the route pattern (e.g. "/api/channels/{channel_id}"), NOT the raw URI.
|
|
// Falling back to raw URI on 404s would create unbounded cardinality from scanners.
|
|
let path = req
|
|
.extensions()
|
|
.get::<MatchedPath>()
|
|
.map(|p| p.as_str().to_owned());
|
|
|
|
// Skip health probes, metrics endpoint, and unmatched paths (404 scanners).
|
|
match path.as_deref() {
|
|
Some(p) if p.starts_with("/_") || p == "/health" || p == "/metrics" => {
|
|
return next.run(req).await;
|
|
}
|
|
None => {
|
|
// No matched route — 404/scanner traffic. Skip to avoid cardinality bomb.
|
|
return next.run(req).await;
|
|
}
|
|
_ => {}
|
|
}
|
|
let action = path.unwrap(); // safe: None case returned above
|
|
|
|
// Caller from Istio header. In CAKE, this is set by the mesh (trusted).
|
|
// On the public TCP listener it's client-controlled, so validate format:
|
|
// only accept short alphanumeric-with-hyphens service names.
|
|
let caller = req
|
|
.headers()
|
|
.get("x-envoy-downstream-service-cluster")
|
|
.and_then(|v| v.to_str().ok())
|
|
.filter(|s| {
|
|
s.len() <= 64
|
|
&& s.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
|
})
|
|
.unwrap_or("unknown")
|
|
.to_owned();
|
|
|
|
let start = Instant::now();
|
|
let response = next.run(req).await;
|
|
let status = response.status().as_u16().to_string();
|
|
let latency_ms = start.elapsed().as_secs_f64() * 1000.0;
|
|
|
|
let labels = [("code", status), ("caller", caller), ("action", action)];
|
|
metrics::counter!("http_requests_total", &labels).increment(1);
|
|
metrics::histogram!("http_request_latency_ms", &labels).record(latency_ms);
|
|
|
|
response
|
|
}
|
|
#[cfg(test)]
|
|
mod contract_tests {
|
|
use std::collections::BTreeSet;
|
|
use std::time::Duration;
|
|
|
|
const OUTCOMES: [&str; 4] = ["success", "timeout", "error", "cancelled"];
|
|
|
|
fn label_keys(line: &str) -> BTreeSet<&str> {
|
|
line.split_once('{')
|
|
.and_then(|(_, rest)| rest.split_once('}'))
|
|
.map(|(labels, _)| {
|
|
labels
|
|
.split(',')
|
|
.filter_map(|label| label.split_once('=').map(|(key, _)| key))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn sample_value<'a>(scrape: &'a str, metric: &str, labels: &[(&str, &str)]) -> &'a str {
|
|
scrape
|
|
.lines()
|
|
.find(|line| {
|
|
line.starts_with(metric)
|
|
&& labels
|
|
.iter()
|
|
.all(|(key, value)| line.contains(&format!(r#"{key}="{value}""#)))
|
|
})
|
|
.and_then(|line| line.rsplit_once(' ').map(|(_, value)| value))
|
|
.unwrap_or_else(|| panic!("missing {metric} with {labels:?} in scrape:\n{scrape}"))
|
|
}
|
|
|
|
#[test]
|
|
fn production_pool_sampler_has_fixed_roles_utilization_and_compatibility_metrics() {
|
|
let (recorder, handle) = super::readiness_test_recorder();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
super::describe_db_pool_metrics();
|
|
super::record_db_pool_metrics(super::DbPoolMetricsInput {
|
|
writer: buzz_db::DbPoolStats {
|
|
size: 12,
|
|
idle: 5,
|
|
max: 20,
|
|
},
|
|
reader: None,
|
|
audit: None,
|
|
search: buzz_db::DbPoolStats {
|
|
size: 4,
|
|
idle: 1,
|
|
max: 10,
|
|
},
|
|
});
|
|
});
|
|
|
|
let scrape = handle.render();
|
|
let connections = scrape
|
|
.lines()
|
|
.filter(|line| line.starts_with("buzz_db_pool_connections{"))
|
|
.collect::<Vec<_>>();
|
|
let max_connections = scrape
|
|
.lines()
|
|
.filter(|line| line.starts_with("buzz_db_pool_max_connections{"))
|
|
.collect::<Vec<_>>();
|
|
let configured = scrape
|
|
.lines()
|
|
.filter(|line| line.starts_with("buzz_db_pool_configured{"))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(connections.len(), 8, "unexpected scrape:\n{scrape}");
|
|
assert_eq!(max_connections.len(), 4, "unexpected scrape:\n{scrape}");
|
|
assert_eq!(configured.len(), 4, "unexpected scrape:\n{scrape}");
|
|
assert_eq!(
|
|
connections.len() + max_connections.len() + configured.len(),
|
|
16,
|
|
"physical pool contract must stay fixed at 16 raw series:\n{scrape}"
|
|
);
|
|
|
|
let roles = connections
|
|
.iter()
|
|
.filter_map(|line| {
|
|
["writer", "reader", "audit", "search"]
|
|
.into_iter()
|
|
.find(|role| line.contains(&format!(r#"pool_role="{role}""#)))
|
|
})
|
|
.collect::<BTreeSet<_>>();
|
|
assert_eq!(
|
|
roles,
|
|
BTreeSet::from(["writer", "reader", "audit", "search"])
|
|
);
|
|
assert!(connections
|
|
.iter()
|
|
.all(|line| label_keys(line) == BTreeSet::from(["pool_role", "state"])));
|
|
assert!(max_connections
|
|
.iter()
|
|
.all(|line| label_keys(line) == BTreeSet::from(["pool_role"])));
|
|
assert!(connections.iter().all(|line| {
|
|
line.contains(r#"state="idle""#) || line.contains(r#"state="active""#)
|
|
}));
|
|
assert!(!connections
|
|
.iter()
|
|
.any(|line| line.contains(r#"state="size""#) || line.contains(r#"state="max""#)));
|
|
|
|
assert_eq!(
|
|
sample_value(
|
|
&scrape,
|
|
"buzz_db_pool_connections{",
|
|
&[("pool_role", "writer"), ("state", "active")]
|
|
),
|
|
"7"
|
|
);
|
|
for state in ["idle", "active"] {
|
|
assert_eq!(
|
|
sample_value(
|
|
&scrape,
|
|
"buzz_db_pool_connections{",
|
|
&[("pool_role", "reader"), ("state", state)]
|
|
),
|
|
"0"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
sample_value(
|
|
&scrape,
|
|
"buzz_db_pool_max_connections{",
|
|
&[("pool_role", "writer")]
|
|
),
|
|
"20"
|
|
);
|
|
assert_eq!(
|
|
sample_value(
|
|
&scrape,
|
|
"buzz_db_pool_max_connections{",
|
|
&[("pool_role", "reader")]
|
|
),
|
|
"0"
|
|
);
|
|
assert_eq!(
|
|
sample_value(
|
|
&scrape,
|
|
"buzz_db_pool_configured{",
|
|
&[("pool_role", "reader")]
|
|
),
|
|
"0"
|
|
);
|
|
assert_eq!(sample_value(&scrape, "buzz_db_pool_size ", &[]), "12");
|
|
assert_eq!(sample_value(&scrape, "buzz_db_pool_idle ", &[]), "5");
|
|
assert_eq!(sample_value(&scrape, "buzz_db_pool_active ", &[]), "7");
|
|
assert_eq!(sample_value(&scrape, "buzz_db_pool_max ", &[]), "20");
|
|
assert!(!scrape.contains("buzz_db_read_pool_size"));
|
|
}
|
|
|
|
#[test]
|
|
fn production_pool_sampler_reports_configured_reader_and_legacy_reader_family() {
|
|
let (recorder, handle) = super::readiness_test_recorder();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
super::record_db_pool_metrics(super::DbPoolMetricsInput {
|
|
writer: buzz_db::DbPoolStats {
|
|
size: 1,
|
|
idle: 1,
|
|
max: 50,
|
|
},
|
|
reader: Some(buzz_db::DbPoolStats {
|
|
size: 9,
|
|
idle: 2,
|
|
max: 15,
|
|
}),
|
|
audit: None,
|
|
search: buzz_db::DbPoolStats {
|
|
size: 1,
|
|
idle: 1,
|
|
max: 10,
|
|
},
|
|
});
|
|
});
|
|
|
|
let scrape = handle.render();
|
|
assert_eq!(
|
|
sample_value(
|
|
&scrape,
|
|
"buzz_db_pool_configured{",
|
|
&[("pool_role", "reader")]
|
|
),
|
|
"1"
|
|
);
|
|
assert_eq!(sample_value(&scrape, "buzz_db_read_pool_size ", &[]), "9");
|
|
assert_eq!(sample_value(&scrape, "buzz_db_read_pool_idle ", &[]), "2");
|
|
assert_eq!(sample_value(&scrape, "buzz_db_read_pool_active ", &[]), "7");
|
|
assert_eq!(sample_value(&scrape, "buzz_db_read_pool_max ", &[]), "15");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn production_pool_sampler_preserves_audit_and_search_pool_provenance() {
|
|
let audit_pool = sqlx::postgres::PgPoolOptions::new()
|
|
.max_connections(5)
|
|
.connect_lazy("postgres://localhost/buzz")
|
|
.expect("construct lazy audit pool");
|
|
let search_pool = sqlx::postgres::PgPoolOptions::new()
|
|
.max_connections(10)
|
|
.connect_lazy("postgres://localhost/buzz")
|
|
.expect("construct lazy search pool");
|
|
let (recorder, handle) = super::readiness_test_recorder();
|
|
|
|
metrics::with_local_recorder(&recorder, || {
|
|
super::record_db_pool_metrics(super::DbPoolMetricsInput {
|
|
writer: buzz_db::DbPoolStats {
|
|
size: 1,
|
|
idle: 1,
|
|
max: 50,
|
|
},
|
|
reader: None,
|
|
audit: Some(buzz_db::DbPoolStats::from_pool(&audit_pool)),
|
|
search: buzz_db::DbPoolStats::from_pool(&search_pool),
|
|
});
|
|
});
|
|
|
|
let scrape = handle.render();
|
|
assert_eq!(
|
|
sample_value(
|
|
&scrape,
|
|
"buzz_db_pool_max_connections{",
|
|
&[("pool_role", "audit")]
|
|
),
|
|
"5"
|
|
);
|
|
assert_eq!(
|
|
sample_value(
|
|
&scrape,
|
|
"buzz_db_pool_max_connections{",
|
|
&[("pool_role", "search")]
|
|
),
|
|
"10"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn production_builder_exports_frozen_db_pool_and_connection_contracts() {
|
|
let (recorder, handle) = super::readiness_test_recorder();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
super::describe_db_pool_metrics();
|
|
for (pool_role, operation) in buzz_db::DB_POOL_ACQUIRE_VALID_PAIRS {
|
|
metrics::counter!(
|
|
"buzz_db_pool_acquire_started_total",
|
|
"pool_role" => pool_role,
|
|
"operation" => operation,
|
|
)
|
|
.increment(1);
|
|
metrics::histogram!(
|
|
"buzz_db_pool_acquire_duration_seconds",
|
|
"pool_role" => pool_role,
|
|
"operation" => operation,
|
|
)
|
|
.record(0.02);
|
|
metrics::gauge!(
|
|
"buzz_db_pool_waiters",
|
|
"pool_role" => pool_role,
|
|
"operation" => operation,
|
|
)
|
|
.set(0.0);
|
|
for outcome in OUTCOMES {
|
|
metrics::counter!(
|
|
"buzz_db_pool_acquire_attempts_total",
|
|
"pool_role" => pool_role,
|
|
"operation" => operation,
|
|
"outcome" => outcome,
|
|
)
|
|
.increment(1);
|
|
}
|
|
}
|
|
for (pool_role, step) in buzz_db::DB_CONNECTION_STARTED_STEPS {
|
|
metrics::counter!(
|
|
"buzz_db_connection_step_started_total",
|
|
"pool_role" => pool_role.as_str(),
|
|
"step" => step.as_str(),
|
|
)
|
|
.increment(1);
|
|
}
|
|
for (pool_role, step) in buzz_db::DB_CONNECTION_DURATION_STEPS {
|
|
metrics::histogram!(
|
|
"buzz_db_connection_step_duration_seconds",
|
|
"pool_role" => pool_role.as_str(),
|
|
"step" => step.as_str(),
|
|
)
|
|
.record(0.02);
|
|
}
|
|
for (pool_role, step, outcome) in buzz_db::DB_CONNECTION_TERMINALS {
|
|
metrics::counter!(
|
|
"buzz_db_connection_step_attempts_total",
|
|
"pool_role" => pool_role.as_str(),
|
|
"step" => step.as_str(),
|
|
"outcome" => outcome.as_str(),
|
|
)
|
|
.increment(1);
|
|
}
|
|
});
|
|
|
|
let scrape = handle.render();
|
|
assert!(scrape.contains("# TYPE buzz_db_pool_acquire_started_total counter"));
|
|
assert!(scrape.contains("# TYPE buzz_db_pool_acquire_duration_seconds histogram"));
|
|
assert!(scrape.contains("# TYPE buzz_db_pool_acquire_attempts_total counter"));
|
|
assert!(scrape.contains("# TYPE buzz_db_pool_waiters gauge"));
|
|
assert!(scrape.contains("# HELP buzz_db_pool_acquire_duration_seconds Database pool checkout duration by valid pool role and operation"));
|
|
assert!(scrape.contains("# HELP buzz_db_pool_acquire_attempts_total Database pool checkout terminals by valid pool role, operation, and outcome"));
|
|
assert!(scrape.contains("# HELP buzz_db_pool_waiters Current tracked-operation database pool checkout attempts in progress by valid pool role and operation"));
|
|
assert!(scrape.contains("# TYPE buzz_db_connection_step_started_total counter"));
|
|
assert!(scrape.contains("# TYPE buzz_db_connection_step_attempts_total counter"));
|
|
assert!(scrape.contains("# TYPE buzz_db_connection_step_duration_seconds histogram"));
|
|
assert_eq!(super::DB_POOL_ACQUIRE_DURATION_UNIT, metrics::Unit::Seconds);
|
|
let readiness_buckets = scrape
|
|
.lines()
|
|
.filter(|line| {
|
|
line.starts_with("buzz_db_pool_acquire_duration_seconds_bucket{")
|
|
&& line.contains("pool_role=\"writer\"")
|
|
&& line.contains("operation=\"readiness\"")
|
|
})
|
|
.map(|line| {
|
|
line.split(",le=\"")
|
|
.nth(1)
|
|
.and_then(|rest| rest.split_once('"').map(|(bucket, _)| bucket))
|
|
.expect("duration bucket carries le label")
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
readiness_buckets,
|
|
["0.001", "0.005", "0.01", "0.025", "0.05", "0.15", "0.5", "1", "3", "+Inf",],
|
|
"duration bucket contract drifted:\n{scrape}"
|
|
);
|
|
|
|
let raw_series = scrape
|
|
.lines()
|
|
.filter(|line| {
|
|
line.starts_with("buzz_db_pool_acquire_started_total")
|
|
|| line.starts_with("buzz_db_pool_acquire_duration_seconds")
|
|
|| line.starts_with("buzz_db_pool_acquire_attempts_total")
|
|
|| line.starts_with("buzz_db_pool_waiters{")
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
raw_series.len(),
|
|
buzz_db::DB_POOL_ACQUIRE_RAW_SERIES_PER_POD,
|
|
"unexpected raw scrape:\n{scrape}"
|
|
);
|
|
|
|
for line in raw_series {
|
|
let keys = label_keys(line);
|
|
if line.starts_with("buzz_db_pool_acquire_duration_seconds_bucket") {
|
|
assert_eq!(keys, BTreeSet::from(["le", "operation", "pool_role"]));
|
|
} else if line.starts_with("buzz_db_pool_acquire_duration_seconds")
|
|
|| line.starts_with("buzz_db_pool_acquire_started_total")
|
|
{
|
|
assert_eq!(keys, BTreeSet::from(["operation", "pool_role"]));
|
|
} else if line.starts_with("buzz_db_pool_acquire_attempts_total") {
|
|
assert_eq!(keys, BTreeSet::from(["operation", "outcome", "pool_role"]));
|
|
} else {
|
|
assert_eq!(keys, BTreeSet::from(["operation", "pool_role"]));
|
|
}
|
|
assert!(!line.contains("operation=\"other\""));
|
|
assert!(!line.contains("result="));
|
|
}
|
|
|
|
let connection_series = scrape
|
|
.lines()
|
|
.filter(|line| {
|
|
line.starts_with("buzz_db_connection_step_started_total")
|
|
|| line.starts_with("buzz_db_connection_step_attempts_total")
|
|
|| line.starts_with("buzz_db_connection_step_duration_seconds")
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
connection_series.len(),
|
|
buzz_db::DB_CONNECTION_RAW_SERIES_PER_POD,
|
|
"unexpected DB connection scrape:\n{scrape}"
|
|
);
|
|
for line in connection_series {
|
|
let keys = label_keys(line);
|
|
if line.starts_with("buzz_db_connection_step_duration_seconds_bucket") {
|
|
assert_eq!(keys, BTreeSet::from(["le", "pool_role", "step"]));
|
|
} else if line.starts_with("buzz_db_connection_step_attempts_total") {
|
|
assert_eq!(keys, BTreeSet::from(["outcome", "pool_role", "step"]));
|
|
} else {
|
|
assert_eq!(keys, BTreeSet::from(["pool_role", "step"]));
|
|
}
|
|
assert!(!line.contains("reason="));
|
|
assert!(!line.contains("connection_ordinal="));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn production_builder_exports_bounded_auth_contract_and_stable_zeros() {
|
|
let (recorder, handle) = super::readiness_test_recorder();
|
|
metrics::with_local_recorder(&recorder, || {
|
|
super::describe_auth_metrics();
|
|
super::initialize_auth_metric_series();
|
|
});
|
|
|
|
let stable_zero_scrape = handle.render();
|
|
assert!(stable_zero_scrape.contains("buzz_auth_attempts_total{method=\"nip42\"} 0"));
|
|
assert!(stable_zero_scrape.contains("buzz_ws_authenticated_connections_active 0"));
|
|
let zero_outcomes = stable_zero_scrape
|
|
.lines()
|
|
.filter(|line| line.starts_with("buzz_auth_outcomes_total{") && line.ends_with(" 0"))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
zero_outcomes.len(),
|
|
super::AuthOutcome::ALL.len(),
|
|
"every bounded outcome must exist before the first attempt:\n{stable_zero_scrape}"
|
|
);
|
|
let zero_duration_counts = stable_zero_scrape
|
|
.lines()
|
|
.filter(|line| {
|
|
line.starts_with("buzz_auth_duration_seconds_count{") && line.ends_with(" 0")
|
|
})
|
|
.count();
|
|
let zero_duration_sums = stable_zero_scrape
|
|
.lines()
|
|
.filter(|line| {
|
|
line.starts_with("buzz_auth_duration_seconds_sum{") && line.ends_with(" 0")
|
|
})
|
|
.count();
|
|
let zero_duration_buckets = stable_zero_scrape
|
|
.lines()
|
|
.filter(|line| {
|
|
line.starts_with("buzz_auth_duration_seconds_bucket{") && line.ends_with(" 0")
|
|
})
|
|
.count();
|
|
assert_eq!(zero_duration_counts, super::AuthOutcome::ALL.len());
|
|
assert_eq!(zero_duration_sums, super::AuthOutcome::ALL.len());
|
|
assert_eq!(
|
|
zero_duration_buckets,
|
|
super::AuthOutcome::ALL.len() * 11,
|
|
"every fixed outcome must export all zero buckets before traffic:\n{stable_zero_scrape}"
|
|
);
|
|
|
|
metrics::with_local_recorder(&recorder, || {
|
|
super::record_auth_attempt_started();
|
|
for outcome in super::AuthOutcome::ALL {
|
|
super::record_auth_outcome(outcome, Duration::from_millis(20));
|
|
}
|
|
for state in super::AuthPostTerminalState::ALL {
|
|
super::record_post_terminal_auth_frame(state);
|
|
}
|
|
let active = metrics::gauge!("buzz_ws_authenticated_connections_active");
|
|
active.increment(1.0);
|
|
active.decrement(1.0);
|
|
});
|
|
|
|
let scrape = handle.render();
|
|
assert!(scrape.contains("# TYPE buzz_auth_attempts_total counter"));
|
|
assert!(scrape.contains("# TYPE buzz_auth_outcomes_total counter"));
|
|
assert!(scrape.contains("# TYPE buzz_auth_duration_seconds histogram"));
|
|
assert!(scrape.contains("# TYPE buzz_ws_authenticated_connections_active gauge"));
|
|
|
|
let raw_series = scrape
|
|
.lines()
|
|
.filter(|line| {
|
|
line.starts_with("buzz_auth_attempts_total{")
|
|
|| line.starts_with("buzz_auth_outcomes_total{")
|
|
|| line.starts_with("buzz_auth_duration_seconds")
|
|
|| line.starts_with("buzz_auth_post_terminal_frames_total{")
|
|
|| line.starts_with("buzz_ws_authenticated_connections_active ")
|
|
})
|
|
.collect::<Vec<_>>();
|
|
// 1 challenge + one series per outcome + 2 post-terminal states + 1 active gauge +, for
|
|
// each outcome, 11 histogram buckets (including +Inf), sum, and count.
|
|
let n = super::AuthOutcome::ALL.len();
|
|
assert_eq!(
|
|
raw_series.len(),
|
|
1 + n + 2 + 1 + n * 13,
|
|
"unexpected raw scrape:\n{scrape}"
|
|
);
|
|
|
|
for line in raw_series {
|
|
let keys = label_keys(line);
|
|
if line.starts_with("buzz_auth_attempts_total{") {
|
|
assert_eq!(keys, BTreeSet::from(["method"]));
|
|
} else if line.starts_with("buzz_auth_outcomes_total{") {
|
|
assert_eq!(keys, BTreeSet::from(["method", "outcome"]));
|
|
} else if line.starts_with("buzz_auth_post_terminal_frames_total{") {
|
|
assert_eq!(keys, BTreeSet::from(["state"]));
|
|
} else if line.starts_with("buzz_auth_duration_seconds_bucket{") {
|
|
assert_eq!(keys, BTreeSet::from(["le", "method", "outcome"]));
|
|
} else if line.starts_with("buzz_auth_duration_seconds") {
|
|
assert_eq!(keys, BTreeSet::from(["method", "outcome"]));
|
|
} else {
|
|
assert!(keys.is_empty(), "active gauge must not add labels: {line}");
|
|
}
|
|
assert!(!line.contains("pubkey="));
|
|
assert!(!line.contains("community="));
|
|
assert!(!line.contains("connection="));
|
|
assert!(!line.contains("pod="));
|
|
assert!(!line.contains("version="));
|
|
assert!(!line.contains("error="));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn occupied_listener_is_classified_as_bind() {
|
|
let listener = std::net::TcpListener::bind(("0.0.0.0", 0)).expect("bind occupied port");
|
|
let port = listener.local_addr().expect("occupied address").port();
|
|
let error = try_install(port, 300).expect_err("occupied listener must fail");
|
|
assert_eq!(error.failure(), MetricsInstallFailure::Bind);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recorder_conflict_is_typed_in_an_isolated_process() {
|
|
const CHILD_ENV: &str = "BUZZ_TEST_METRICS_RECORDER_CONFLICT";
|
|
if std::env::var_os(CHILD_ENV).is_some() {
|
|
let recorder = configured_prometheus_builder(300).build_recorder();
|
|
metrics::set_global_recorder(recorder).expect("install first recorder");
|
|
let error = try_install(0, 300).expect_err("second recorder must fail");
|
|
assert_eq!(error.failure(), MetricsInstallFailure::RecorderConflict);
|
|
return;
|
|
}
|
|
|
|
crate::test_support::run_exact_test_child(
|
|
"metrics::tests::recorder_conflict_is_typed_in_an_isolated_process",
|
|
CHILD_ENV,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fixtures for the partition audit alerts in
|
|
/// `docs/partition-catalog-monitoring.md`: the production exporter configuration
|
|
/// and audit metric emission export the series those rules read.
|
|
#[cfg(test)]
|
|
mod partition_alert_tests {
|
|
use std::collections::BTreeMap;
|
|
use std::time::Duration;
|
|
|
|
const RUNS: &str = "buzz_partition_audit_runs_total";
|
|
const LAST_SUCCESS: &str = "buzz_partition_audit_last_success_timestamp_seconds";
|
|
const MANAGED_TABLES: [&str; 2] = ["delivery_log", "events"];
|
|
|
|
/// Sample value by `table` label for every sample of `metric` in a scrape.
|
|
/// Counters with several `outcome` labels keep the last sample per table.
|
|
fn samples(scrape: &str, metric: &str) -> BTreeMap<String, f64> {
|
|
scrape
|
|
.lines()
|
|
.filter_map(|line| line.strip_prefix(metric)?.strip_prefix('{'))
|
|
.filter_map(|labels| {
|
|
let (_, rest) = labels.split_once(r#"table=""#)?;
|
|
let (table, rest) = rest.split_once('"')?;
|
|
let (_, value) = rest.rsplit_once(' ')?;
|
|
Some((table.to_string(), value.parse().expect("numeric sample")))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn tables(scrape: &str, metric: &str) -> Vec<String> {
|
|
samples(scrape, metric).into_keys().collect()
|
|
}
|
|
|
|
fn managed_tables() -> Vec<String> {
|
|
MANAGED_TABLES
|
|
.iter()
|
|
.map(|table| table.to_string())
|
|
.collect()
|
|
}
|
|
|
|
fn unreachable_db() -> buzz_db::Db {
|
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
|
.acquire_timeout(Duration::from_millis(200))
|
|
.connect_lazy("postgres://127.0.0.1:1/buzz")
|
|
.expect("lazy test pool");
|
|
buzz_db::Db::from_pool(pool)
|
|
}
|
|
|
|
fn current_thread_runtime() -> tokio::runtime::Runtime {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("current-thread runtime")
|
|
}
|
|
|
|
#[test]
|
|
fn never_successful_audits_export_a_zero_success_timestamp() {
|
|
let (recorder, handle) = super::readiness_test_recorder_with_idle_timeout(1);
|
|
metrics::with_local_recorder(&recorder, || {
|
|
current_thread_runtime().block_on(async {
|
|
assert!(unreachable_db().audit_partitions(3).await.is_err());
|
|
});
|
|
});
|
|
|
|
let scrape = handle.render();
|
|
assert_eq!(tables(&scrape, RUNS), managed_tables(), "{scrape}");
|
|
let zero = MANAGED_TABLES
|
|
.iter()
|
|
.map(|table| (table.to_string(), 0.0))
|
|
.collect::<BTreeMap<_, _>>();
|
|
assert_eq!(samples(&scrape, LAST_SUCCESS), zero, "{scrape}");
|
|
}
|
|
|
|
#[test]
|
|
fn audit_counters_export_zero_baselines_before_the_first_audit() {
|
|
let (recorder, handle) = super::readiness_test_recorder_with_idle_timeout(1);
|
|
metrics::with_local_recorder(&recorder, super::initialize_partition_audit_metric_series);
|
|
|
|
let scrape = handle.render();
|
|
let zero_runs = scrape
|
|
.lines()
|
|
.filter(|line| line.starts_with(&format!("{RUNS}{{")) && line.ends_with(" 0"))
|
|
.count();
|
|
assert_eq!(zero_runs, MANAGED_TABLES.len() * 3, "{scrape}");
|
|
assert!(
|
|
scrape
|
|
.lines()
|
|
.any(|line| line == "buzz_partition_audit_failures_total 0"),
|
|
"{scrape}"
|
|
);
|
|
|
|
// A first failure then reads as an increase from the zero baseline.
|
|
metrics::with_local_recorder(&recorder, || {
|
|
current_thread_runtime().block_on(async {
|
|
assert!(unreachable_db().audit_partitions(3).await.is_err());
|
|
});
|
|
});
|
|
let scrape = handle.render();
|
|
for table in MANAGED_TABLES {
|
|
for (outcome, value) in [("ok", 0), ("degraded", 0), ("error", 1)] {
|
|
let line = scrape
|
|
.lines()
|
|
.find(|line| {
|
|
line.starts_with(&format!("{RUNS}{{"))
|
|
&& line.contains(&format!(r#"table="{table}""#))
|
|
&& line.contains(&format!(r#"outcome="{outcome}""#))
|
|
})
|
|
.unwrap_or_else(|| panic!("{table}/{outcome} missing:\n{scrape}"));
|
|
assert!(line.ends_with(&format!(" {value}")), "{line}");
|
|
}
|
|
}
|
|
}
|
|
|
|
mod postgres_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
#[ignore = "requires Postgres"]
|
|
fn failed_audits_keep_the_last_success_past_the_idle_timeout() {
|
|
let idle_timeout = Duration::from_secs(1);
|
|
let (recorder, handle) =
|
|
crate::metrics::readiness_test_recorder_with_idle_timeout(idle_timeout.as_secs());
|
|
let runtime = current_thread_runtime();
|
|
let (working, failing) = metrics::with_local_recorder(&recorder, || {
|
|
runtime.block_on(async {
|
|
let pool = sqlx::PgPool::connect(&crate::test_support::database_url())
|
|
.await
|
|
.expect("connect test database");
|
|
let working = buzz_db::Db::from_pool(pool);
|
|
working.audit_partitions(3).await.expect("successful audit");
|
|
(working, unreachable_db())
|
|
})
|
|
});
|
|
|
|
let scrape = handle.render();
|
|
let first = samples(&scrape, LAST_SUCCESS);
|
|
assert_eq!(first.keys().cloned().collect::<Vec<_>>(), managed_tables());
|
|
|
|
let audit = |db: &buzz_db::Db| {
|
|
metrics::with_local_recorder(&recorder, || {
|
|
runtime.block_on(async { db.audit_partitions(3).await.is_ok() })
|
|
})
|
|
};
|
|
// A failed audit never makes the last success look fresh, and
|
|
// sustained failures keep it exported past the idle timeout, so its
|
|
// age keeps rising instead of the series disappearing.
|
|
assert!(!audit(&failing));
|
|
assert_eq!(samples(&handle.render(), LAST_SUCCESS), first);
|
|
std::thread::sleep(idle_timeout + Duration::from_millis(250));
|
|
assert!(!audit(&failing));
|
|
assert_eq!(samples(&handle.render(), LAST_SUCCESS), first);
|
|
|
|
// When audits stop running altogether the timestamp is evicted,
|
|
// but the run counters remain for the not-running rule.
|
|
std::thread::sleep(idle_timeout + Duration::from_millis(250));
|
|
let scrape = handle.render();
|
|
assert!(tables(&scrape, LAST_SUCCESS).is_empty(), "{scrape}");
|
|
assert_eq!(tables(&scrape, RUNS), managed_tables(), "{scrape}");
|
|
|
|
// The next success exports a fresh timestamp again.
|
|
assert!(audit(&working));
|
|
assert_eq!(tables(&handle.render(), LAST_SUCCESS), managed_tables());
|
|
}
|
|
}
|
|
}
|