Skip to content

Commit fabe2f3

Browse files
feat: Stellar submission failures metrics (#670)
Co-authored-by: Nicolas Molina <nicolas.molina@openzeppelin.com>
1 parent f720040 commit fabe2f3

3 files changed

Lines changed: 80 additions & 4 deletions

File tree

src/domain/transaction/stellar/submit.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use super::{
1313
use crate::{
1414
constants::STELLAR_INSUFFICIENT_FEE_MAX_RETRIES,
1515
jobs::JobProducerTrait,
16+
metrics::STELLAR_SUBMISSION_FAILURES,
1617
models::{
1718
NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionMetadata,
1819
TransactionRepoModel, TransactionStatus, TransactionUpdateRequest,
@@ -105,7 +106,12 @@ where
105106
.provider()
106107
.send_transaction_with_status(&tx_envelope)
107108
.await
108-
.map_err(TransactionError::from)?;
109+
.map_err(|e| {
110+
STELLAR_SUBMISSION_FAILURES
111+
.with_label_values(&["provider_error", "n/a"])
112+
.inc();
113+
TransactionError::from(e)
114+
})?;
109115

110116
// Handle status codes from the RPC response
111117
match response.status.as_str() {
@@ -200,6 +206,9 @@ where
200206
.saturating_add(1);
201207

202208
if insufficient_fee_retries > STELLAR_INSUFFICIENT_FEE_MAX_RETRIES {
209+
STELLAR_SUBMISSION_FAILURES
210+
.with_label_values(&["error", "tx_insufficient_fee"])
211+
.inc();
203212
return Err(TransactionError::UnexpectedError(format!(
204213
"Transaction submission error: insufficient fee retry limit exceeded ({STELLAR_INSUFFICIENT_FEE_MAX_RETRIES})"
205214
)));
@@ -234,14 +243,22 @@ where
234243
.await?;
235244
return Ok(updated_tx);
236245
}
237-
246+
STELLAR_SUBMISSION_FAILURES
247+
.with_label_values(&[
248+
"error",
249+
decoded_result_code.as_deref().unwrap_or("unknown"),
250+
])
251+
.inc();
238252
Err(TransactionError::UnexpectedError(format!(
239253
"Transaction submission error: {}",
240254
decoded_result_code.unwrap_or(error_detail)
241255
)))
242256
}
243257
unknown => {
244258
// Unknown status - treat as error
259+
STELLAR_SUBMISSION_FAILURES
260+
.with_label_values(&["unknown_status", "n/a"])
261+
.inc();
245262
warn!(
246263
tx_id = %tx.id,
247264
relayer_id = %tx.relayer_id,

src/domain/transaction/stellar/utils.rs

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use soroban_rs::xdr::{
1313
AccountId, AlphaNum12, AlphaNum4, Asset, ChangeTrustAsset, ContractDataEntry, ContractId, Hash,
1414
LedgerEntryData, LedgerKey, LedgerKeyContractData, Limits, Operation, Preconditions,
1515
PublicKey as XdrPublicKey, ReadXdr, ScAddress, ScSymbol, ScVal, TimeBounds, TimePoint,
16-
TransactionEnvelope, TransactionMeta, Uint256, VecM,
16+
TransactionEnvelope, TransactionMeta, TransactionResult, Uint256, VecM,
1717
};
1818
use std::str::FromStr;
1919
use stellar_strkey::ed25519::PublicKey;
@@ -266,8 +266,18 @@ pub fn i64_from_u64(value: u64) -> Result<i64, RelayerError> {
266266
i64::try_from(value).map_err(|_| RelayerError::ProviderError("u64→i64 overflow".into()))
267267
}
268268

269+
/// Decodes a base64-encoded `TransactionResult` XDR into a human-readable result code name.
270+
///
271+
/// Returns the variant name of the `TransactionResultResult` (e.g., `"TxBadSeq"`,
272+
/// `"TxInsufficientBalance"`, `"TxFailed"`). Returns `None` if the XDR cannot be decoded.
273+
pub fn decode_tx_result_code(error_result_xdr: &str) -> Option<String> {
274+
TransactionResult::from_xdr_base64(error_result_xdr, Limits::none())
275+
.ok()
276+
.map(|r| r.result.name().to_string())
277+
}
278+
269279
/// Detects if an error is due to a bad sequence number.
270-
/// Returns true if the error message contains indicators of sequence number mismatch.
280+
/// Checks both string matching on the error message and XDR decoding when available.
271281
pub fn is_bad_sequence_error(error_msg: &str) -> bool {
272282
let error_lower = error_msg.to_lowercase();
273283
error_lower.contains("txbadseq")
@@ -1475,6 +1485,46 @@ mod tests {
14751485
}
14761486
}
14771487

1488+
mod decode_tx_result_code_tests {
1489+
use super::*;
1490+
use soroban_rs::xdr::{TransactionResult, TransactionResultResult, WriteXdr};
1491+
1492+
#[test]
1493+
fn test_decodes_tx_bad_seq() {
1494+
let result = TransactionResult {
1495+
fee_charged: 100,
1496+
result: TransactionResultResult::TxBadSeq,
1497+
ext: soroban_rs::xdr::TransactionResultExt::V0,
1498+
};
1499+
let xdr = result.to_xdr_base64(Limits::none()).unwrap();
1500+
assert_eq!(decode_tx_result_code(&xdr), Some("TxBadSeq".to_string()));
1501+
}
1502+
1503+
#[test]
1504+
fn test_decodes_tx_insufficient_balance() {
1505+
let result = TransactionResult {
1506+
fee_charged: 100,
1507+
result: TransactionResultResult::TxInsufficientBalance,
1508+
ext: soroban_rs::xdr::TransactionResultExt::V0,
1509+
};
1510+
let xdr = result.to_xdr_base64(Limits::none()).unwrap();
1511+
assert_eq!(
1512+
decode_tx_result_code(&xdr),
1513+
Some("TxInsufficientBalance".to_string())
1514+
);
1515+
}
1516+
1517+
#[test]
1518+
fn test_returns_none_for_invalid_xdr() {
1519+
assert_eq!(decode_tx_result_code("not-valid-xdr"), None);
1520+
}
1521+
1522+
#[test]
1523+
fn test_returns_none_for_empty_string() {
1524+
assert_eq!(decode_tx_result_code(""), None);
1525+
}
1526+
}
1527+
14781528
mod is_insufficient_fee_error_tests {
14791529
use super::*;
14801530

src/metrics/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,15 @@ lazy_static! {
199199
histogram_vec
200200
};
201201

202+
// Counter for Stellar transaction submission failures with decoded result codes.
203+
pub static ref STELLAR_SUBMISSION_FAILURES: CounterVec = {
204+
let opts = Opts::new("stellar_submission_failures_total",
205+
"Stellar transaction submission failures by status and result code");
206+
let counter_vec = CounterVec::new(opts, &["submit_status", "result_code"]).unwrap();
207+
REGISTRY.register(Box::new(counter_vec.clone())).unwrap();
208+
counter_vec
209+
};
210+
202211
// Counter for plugin calls (tracks requests to /api/v1/plugins/{plugin_id}/call endpoints).
203212
pub static ref PLUGIN_CALLS: CounterVec = {
204213
let opts = Opts::new("plugin_calls_total", "Total number of plugin calls");

0 commit comments

Comments
 (0)