Skip to content

Commit 0aba830

Browse files
mbjorkqvistclaude
andauthored
test: DEFI-2810: E2e coverage for labeled metrics and doc cleanups (#310)
## Summary Final canister-side PR for [DEFI-2810](https://dfinity.atlassian.net/browse/DEFI-2810). Builds on #308 (labeled-metrics foundation + per-forex) and #309 (per-exchange + per-stablecoin) by adding end-to-end test coverage for every labeled series introduced across the earlier two PRs, plus small doc cleanups. The corresponding Prometheus alert rules ship separately in [dfinity-ops/k8s#2459](dfinity-ops/k8s#2459). - **E2e integration test** (`xrc-tests/metrics_endpoint`) — boots the canister against the docker mock, drives a `get_exchange_rate` request with one exchange configured to fail, scrapes `/metrics` via the `http_request` candid endpoint, and asserts every labeled metric family from PRs 1 & 2 is present, including a non-success outcome on the per-exchange counter. Includes a regression guard: each labeled metric name emits exactly one `# HELP` line. - **Group B `# HELP` annotations** — `xrc_stablecoin_errors`, `xrc_crypto_asset_errors`, and `xrc_forex_asset_errors` now point dashboard authors at the per-source replacements from PRs 1 & 2. Pure docstring change. - **DEFI-2828 TODO** on the new test's `#[ignore]` attribute, describing the follow-up to replace the implicit "requires Docker mock harness" convention with an explicit gate (reason string / feature flag). ### Note on the dropped collector-sources gauge An earlier draft of this PR added `xrc_forex_collector_sources{slot}` exposing per-deque-slot source counts. It was dropped during review (commit `9c1497d`) — the collector deque is only mutated on successful source fetches and has no wall-clock expiry, so a sustained outage leaves the gauge reporting stale counts for arbitrarily old days while `== 0` only fires on a cold-start empty deque. The signals operators actually need are already on `/metrics` from #308 / #309 and covered by the new e2e test — and operationalised as alerts in [dfinity-ops/k8s#2459](dfinity-ops/k8s#2459): | Scenario | Alert | |---|---| | All forex data is stale (errors, empty, unreachable, _or_ timer dead) | `time() - min(xrc_forex_last_success_seconds) > 86400` | | Timer not firing | `time() - xrc_periodic_forex_run_last_seconds > 1800` | | Any individual source down for an extended period | `time() - xrc_forex_last_success_seconds > 172800` | ## Test plan - [x] `cargo test -p xrc` — 215 unit tests pass post-revert. - [x] `./scripts/e2e-tests` — passed at the gauge-included revision (`84aaca7`); the post-revert change to `metrics_endpoint.rs` only removes assertions, which cannot regress a previously passing test. ## Follow-up [DEFI-2828](https://dfinity.atlassian.net/browse/DEFI-2828) tracks replacing the `#[ignore]` gating in `xrc-tests` with an explicit gate (reason string / feature flag). 🤖 Generated with [Claude Code](https://claude.com/claude-code) [DEFI-2810]: https://dfinity.atlassian.net/browse/DEFI-2810 [DEFI-2828]: https://dfinity.atlassian.net/browse/DEFI-2828 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 502f263 commit 0aba830

3 files changed

Lines changed: 206 additions & 5 deletions

File tree

src/xrc-tests/src/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod basic_exchange_rates;
44
mod caching;
55
mod determinism;
66
mod get_icp_xdr_rate;
7+
mod metrics_endpoint;
78
mod misbehavior;
89

910
/// The total number of exchanges.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
use ic_xrc_types::{Asset, AssetClass, GetExchangeRateRequest, GetExchangeRateResult};
2+
use maplit::hashmap;
3+
use xrc::types::{HttpRequest, HttpResponse};
4+
5+
use crate::{
6+
container::{run_scenario, Container},
7+
mock_responses,
8+
};
9+
10+
/// Setup:
11+
/// * Deploy mock FOREX data providers and exchanges, with one exchange
12+
/// configured to return no body (driving a non-success outcome on the
13+
/// exchange counter).
14+
/// * Start replicas and deploy the XRC, configured to use the mock data
15+
/// sources.
16+
///
17+
/// Runbook:
18+
/// * Drive a `get_exchange_rate` request so the recording sites fire across
19+
/// the crypto, stablecoin, and forex code paths.
20+
/// * Scrape the `/metrics` endpoint via the canister's `http_request` query.
21+
///
22+
/// Success criteria:
23+
/// * Every labeled metric family is present on `/metrics`:
24+
/// `xrc_exchange_fetch_total` (with both success and a non-success outcome
25+
/// represented), `xrc_exchange_last_success_seconds`,
26+
/// `xrc_forex_fetch_total`, `xrc_forex_last_success_seconds`,
27+
/// `xrc_periodic_forex_run_last_seconds`, and
28+
/// `xrc_stablecoin_symbol_rates_received`.
29+
/// * Each labeled metric name emits exactly one `# HELP` line — regression
30+
/// guard on the labeled-encoder header dedup logic.
31+
// TODO(DEFI-2828): Replace `#[ignore]` with a clearer gate (reason string /
32+
// feature flag) across the xrc-tests crate so the intent — "requires the
33+
// Docker mock harness via ./scripts/e2e-tests" — is explicit rather than
34+
// implicit.
35+
#[ignore]
36+
#[test]
37+
fn metrics_endpoint_exposes_all_labeled_series() {
38+
let now_seconds = time::OffsetDateTime::now_utc().unix_timestamp() as u64;
39+
let timestamp_seconds = now_seconds / 60 * 60;
40+
41+
let responses = mock_responses::exchanges::build_responses(
42+
"ICP".to_string(),
43+
timestamp_seconds,
44+
|exchange| match exchange {
45+
// KuCoin returns no body — drives a non-success outcome on the
46+
// crypto fetch path for this exchange.
47+
xrc::Exchange::KuCoin(_) => None,
48+
xrc::Exchange::Coinbase(_) => Some("3.92"),
49+
xrc::Exchange::Okx(_) => Some("3.90"),
50+
xrc::Exchange::GateIo(_) => Some("3.90"),
51+
xrc::Exchange::Mexc(_) => Some("3.911"),
52+
xrc::Exchange::Poloniex(_) => Some("4.005"),
53+
xrc::Exchange::CryptoCom(_) => Some("3.91"),
54+
xrc::Exchange::Bitget(_) => Some("3.93"),
55+
xrc::Exchange::Digifinex(_) => Some("4.00"),
56+
},
57+
)
58+
.chain(mock_responses::stablecoin::build_responses(
59+
timestamp_seconds,
60+
))
61+
.chain(mock_responses::forex::build_responses(
62+
now_seconds,
63+
|_| Some(hashmap! { "EUR" => "1.05" }),
64+
))
65+
.collect::<Vec<_>>();
66+
67+
let container = Container::builder()
68+
.name("metrics_endpoint")
69+
.exchange_responses(responses)
70+
.build();
71+
72+
run_scenario(container, |container| {
73+
// Drive a request so the per-exchange and per-stablecoin recording
74+
// sites fire. The inner `GetExchangeRateResult` is intentionally not
75+
// asserted on — value correctness lives in basic_exchange_rates.rs /
76+
// misbehavior.rs. The outer call itself is `expect`-ed so an
77+
// infrastructure failure (dfx error, candid decode) fails the test
78+
// with a clear root-cause instead of silently propagating into the
79+
// metrics assertions below.
80+
let request = GetExchangeRateRequest {
81+
timestamp: Some(timestamp_seconds),
82+
base_asset: Asset {
83+
symbol: "ICP".to_string(),
84+
class: AssetClass::Cryptocurrency,
85+
},
86+
quote_asset: Asset {
87+
symbol: "EUR".to_string(),
88+
class: AssetClass::FiatCurrency,
89+
},
90+
};
91+
let _ = container
92+
.call_canister::<_, GetExchangeRateResult>("get_exchange_rate", &request)
93+
.expect("get_exchange_rate canister call must succeed");
94+
95+
// Scrape /metrics via the canister's http_request endpoint.
96+
let response = container
97+
.call_canister::<HttpRequest, HttpResponse>(
98+
"http_request",
99+
&HttpRequest {
100+
method: "GET".to_string(),
101+
url: "/metrics".to_string(),
102+
headers: vec![],
103+
body: Default::default(),
104+
},
105+
)
106+
.expect("Failed to scrape /metrics");
107+
108+
assert_eq!(response.status_code, 200, "/metrics returned non-200");
109+
let body =
110+
String::from_utf8(response.body.into_vec()).expect("/metrics body must be UTF-8");
111+
112+
// Per-exchange counter — success for a healthy exchange, non-success
113+
// for the failing one. Assertions include the full `{exchange, kind,
114+
// outcome}` label set so a regression that silently drops or renames
115+
// one of those label keys fails the test instead of slipping through
116+
// on a name-only prefix match.
117+
assert!(
118+
body.contains(
119+
r#"xrc_exchange_fetch_total{exchange="Coinbase",kind="crypto",outcome="success"}"#
120+
),
121+
"missing Coinbase crypto success series\n{body}"
122+
);
123+
let kucoin_failure_line = body.lines().find(|line| {
124+
line.starts_with(r#"xrc_exchange_fetch_total{exchange="KuCoin","#)
125+
&& line.contains(r#"kind="crypto""#)
126+
&& !line.contains(r#"outcome="success""#)
127+
});
128+
assert!(
129+
kucoin_failure_line.is_some(),
130+
"missing non-success outcome for KuCoin crypto\n{body}"
131+
);
132+
133+
// Per-exchange last-success gauge — present at minimum because init
134+
// seeded it; ideally bumped by the successful Coinbase fetch.
135+
assert!(
136+
body.contains(r#"xrc_exchange_last_success_seconds{exchange="Coinbase""#),
137+
"missing xrc_exchange_last_success_seconds for Coinbase\n{body}"
138+
);
139+
140+
// Per-forex counter — at least one success line. The empty_map and
141+
// other non-success outcomes are exercised in unit tests
142+
// (`periodic::test::empty_map_error_is_recorded_as_empty_map_outcome`
143+
// and friends); here we only verify that the labeled-encoder pipe
144+
// reaches `/metrics`.
145+
assert!(
146+
body.lines().any(|line| line
147+
.starts_with("xrc_forex_fetch_total{")
148+
&& line.contains(r#"outcome="success""#)),
149+
"missing success outcome on the forex counter\n{body}"
150+
);
151+
152+
// Per-forex last-success gauge — seeded by init, present for every
153+
// configured source.
154+
assert!(
155+
body.contains("xrc_forex_last_success_seconds{forex="),
156+
"missing xrc_forex_last_success_seconds series\n{body}"
157+
);
158+
159+
// Stablecoin per-symbol counters — both bases.
160+
assert!(
161+
body.contains(r#"xrc_stablecoin_symbol_rates_received{symbol="USDS"}"#),
162+
"missing stablecoin counter for USDS\n{body}"
163+
);
164+
assert!(
165+
body.contains(r#"xrc_stablecoin_symbol_rates_received{symbol="USDC"}"#),
166+
"missing stablecoin counter for USDC\n{body}"
167+
);
168+
169+
// Periodic-task heartbeat — set on every Success return of
170+
// update_forex_store.
171+
assert!(
172+
body.contains("xrc_periodic_forex_run_last_seconds"),
173+
"missing heartbeat gauge\n{body}"
174+
);
175+
176+
// Each labeled metric name should emit exactly one `# HELP` line,
177+
// regardless of how many series share that name.
178+
for name in [
179+
"xrc_exchange_fetch_total",
180+
"xrc_exchange_last_success_seconds",
181+
"xrc_forex_fetch_total",
182+
"xrc_forex_last_success_seconds",
183+
"xrc_periodic_forex_run_last_seconds",
184+
"xrc_stablecoin_symbol_rates_received",
185+
] {
186+
let help_prefix = format!("# HELP {} ", name);
187+
let help_count = body.lines().filter(|l| l.starts_with(&help_prefix)).count();
188+
assert_eq!(
189+
help_count, 1,
190+
"expected exactly one `# HELP {name}` line, found {help_count}\n{body}"
191+
);
192+
}
193+
194+
Ok(())
195+
})
196+
.expect("Scenario failed");
197+
}

src/xrc/src/api/metrics.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::{
2-
rate_limiting, types::HttpResponse, with_cache, with_forex_rate_store,
3-
with_labeled_counters, with_labeled_gauges, AllocatedBytes, MetricCounter, MetricName,
2+
rate_limiting, types::HttpResponse, with_cache, with_forex_rate_store, with_labeled_counters,
3+
with_labeled_gauges, AllocatedBytes, MetricCounter, MetricName,
44
};
55
use ic_cdk::api::time;
66
use serde_bytes::ByteBuf;
@@ -100,19 +100,22 @@ fn encode_metrics(w: &mut MetricsEncoder<Vec<u8>>) -> std::io::Result<()> {
100100
w.encode_counter(
101101
"xrc_stablecoin_errors",
102102
MetricCounter::StablecoinErrorsReturned.get() as u64,
103-
"The number of stablecoin errors returned.",
103+
"The number of stablecoin errors returned (aggregate; \
104+
see xrc_stablecoin_symbol_rates_received for per-symbol liquidity health).",
104105
)?;
105106

106107
w.encode_counter(
107108
"xrc_crypto_asset_errors",
108109
MetricCounter::CryptoAssetRelatedErrorsReturned.get() as u64,
109-
"The number of crypto asset related errors returned.",
110+
"The number of crypto asset related errors returned (aggregate; \
111+
see xrc_exchange_fetch_total for per-exchange outcomes).",
110112
)?;
111113

112114
w.encode_counter(
113115
"xrc_forex_asset_errors",
114116
MetricCounter::ForexAssetRelatedErrorsReturned.get() as u64,
115-
"The number of forex asset related errors returned.",
117+
"The number of forex asset related errors returned (aggregate; \
118+
see xrc_forex_fetch_total for per-source outcomes).",
116119
)?;
117120

118121
w.encode_counter(

0 commit comments

Comments
 (0)