feat(fips): fallback telemetry — per-reason counters in fips.status + last-transport recording on all dial sites

Phase A2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md (RC6). Fallbacks to Tor
were debug!-only and uncounted, so "FIPS uptime" was unfalsifiable and
paths that were 100% Tor by construction went unnoticed for months.

- fips::telemetry: process-lifetime counters for FIPS successes and the
  six fallback reasons (no_npub, service_inactive, dns_fail, connect_fail,
  http_404, http_5xx), exposed as `dial_stats` in fips.status
- dial.rs: every fallback branch now counts + logs at info! with a
  `reason` field (resolve/connect/status branches)
- PeerRequest::record_transport(data_dir): opt-in hook that writes the
  transport actually used to federation storage off the hot path — wired
  into the dial sites that never recorded (DWN sync ×3, mesh blob fetch,
  federation deploy notify, onion-rotation notify, node messages via a
  new send_to_peer data-dir param)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-27 20:54:35 -04:00
co-authored by Claude Fable 5
parent eb2fc0f37b
commit e24e0a6473
12 changed files with 267 additions and 11 deletions
+91 -6
View File
@@ -24,6 +24,7 @@
//! ```
#![allow(dead_code)]
use super::telemetry::{self, FallbackReason};
use anyhow::{Context, Result};
use std::net::{IpAddr, Ipv6Addr};
use std::time::Duration;
@@ -317,6 +318,11 @@ pub struct PeerRequest<'a> {
/// large content download needs so its long FIPS transfer isn't truncated.
pub fips_timeout: Option<std::time::Duration>,
pub service: Option<crate::settings::transport::PeerService>,
/// When set, the transport that actually served this request is written
/// to federation storage (`record_peer_transport`, matched by onion) so
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>,
}
impl<'a> PeerRequest<'a> {
@@ -329,6 +335,31 @@ impl<'a> PeerRequest<'a> {
timeout: std::time::Duration::from_secs(30),
fips_timeout: None,
service: None,
record_data_dir: None,
}
}
/// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path.
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
self.record_data_dir = Some(data_dir.into());
self
}
fn spawn_record(&self, kind: crate::transport::TransportKind) {
if let Some(dir) = &self.record_data_dir {
let dir = dir.clone();
let onion = self.onion_host.to_string();
let transport = kind.to_string();
tokio::spawn(async move {
let _ = crate::federation::record_peer_transport(
&dir,
None,
Some(&onion),
&transport,
)
.await;
});
}
}
@@ -389,8 +420,22 @@ impl<'a> PeerRequest<'a> {
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
}
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
FallbackReason::Http404
} else {
FallbackReason::Http5xx
};
telemetry::record_fallback(reason);
tracing::info!(
reason = reason.key(),
status = %resp.status(),
"FIPS POST {} answered but status triggers Tor fallback",
self.path
);
}
None => {
if pref == TransportPref::Fips {
@@ -402,6 +447,7 @@ impl<'a> PeerRequest<'a> {
}
}
let resp = self.send_tor_post_json(body).await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor))
}
@@ -413,8 +459,22 @@ impl<'a> PeerRequest<'a> {
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
}
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
FallbackReason::Http404
} else {
FallbackReason::Http5xx
};
telemetry::record_fallback(reason);
tracing::info!(
reason = reason.key(),
status = %resp.status(),
"FIPS GET {} answered but status triggers Tor fallback",
self.path
);
}
None => {
if pref == TransportPref::Fips {
@@ -426,6 +486,7 @@ impl<'a> PeerRequest<'a> {
}
}
let resp = self.send_tor_get().await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor))
}
@@ -434,15 +495,23 @@ impl<'a> PeerRequest<'a> {
body: &B,
) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None);
};
if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None);
}
let base = match peer_base_url(npub).await {
Ok(b) => b,
Err(e) => {
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
return Ok(None);
}
};
@@ -467,7 +536,9 @@ impl<'a> PeerRequest<'a> {
match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) => {
tracing::debug!(
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS POST {} failed after retry: {}, falling back to Tor",
url,
e
@@ -475,7 +546,9 @@ impl<'a> PeerRequest<'a> {
Ok(None)
}
Err(_) => {
tracing::debug!(
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
url,
budget
@@ -487,15 +560,23 @@ impl<'a> PeerRequest<'a> {
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None);
};
if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None);
}
let base = match peer_base_url(npub).await {
Ok(b) => b,
Err(e) => {
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
return Ok(None);
}
};
@@ -516,7 +597,9 @@ impl<'a> PeerRequest<'a> {
match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) => {
tracing::debug!(
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS GET {} failed after retry: {}, falling back to Tor",
url,
e
@@ -524,7 +607,9 @@ impl<'a> PeerRequest<'a> {
Ok(None)
}
Err(_) => {
tracing::debug!(
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
url,
budget