feat(telemetry): Emit every account a transaction names on tx.process

A transaction names one or more accounts: the sender in Account, and by
type a Destination, Owner, Issuer, Holder and so on. tx.process now
carries each top-level account-typed field as its own attribute, keyed
tx_<field> in lower snake case (tx_account, tx_destination, ...), so an
account can be searched for in traces whatever role it played.
Addresses are public ledger identifiers and are emitted raw.

The keys live in TxAccountSpanNames.h in libxrpl, with a field-to-key
table in TxAccountSpanNames.cpp. A library test walks TxFormats and the
SField registry: every account field a transaction can carry has a key,
and no field that only ledger entries carry has one. An empty account
field is skipped rather than rendered as the zero address.
This commit is contained in:
Pratik Mankawde
2026-09-23 18:01:14 +01:00
parent 652b0c0dbf
commit 509fc7853e
7 changed files with 417 additions and 19 deletions

View File

@@ -487,24 +487,25 @@ This gives the best of both worlds: guaranteed cross-node correlation via determ
**Attributes added**:
| Span | Attribute | Type | Source |
| ----------------- | -------------------- | ------ | ------------------------------------------------------------------- |
| `tx.process` | `tx_type` | string | `TxFormats::getInstance().findByType(stx->getTxnType())->getName()` |
| `tx.process` | `fee` | int64 | `stx->getFieldAmount(sfFee).xrp().drops()` |
| `tx.process` | `sequence` | int64 | `stx->getSeqProxy().value()` |
| `tx.process` | `ter_result` | string | `transToken(e.result)` (set after batch application) |
| `tx.process` | `applied` | bool | `e.applied` (set after batch application) |
| `tx.receive` | `tx_type` | string | `TxFormats::getInstance().findByType(stx->getTxnType())->getName()` |
| `txq.enqueue` | `tx_type` | string | same pattern as above |
| `txq.enqueue` | `txq_status` | string | `queued` / `applied_direct` / `applied` / `failed` / `rejected` |
| `txq.enqueue` | `ter_code` | string | `transToken(directApplied->ter)` (set on the direct-apply path) |
| `txq.enqueue` | `fee_level_paid` | int64 | `getFeeLevelPaid(view, *tx).value()` |
| `txq.enqueue` | `required_fee_level` | int64 | `getRequiredFeeLevel(...).value()` |
| `txq.batch_clear` | `num_cleared` | int64 | queued txs cleared ahead of the applying tx |
| `txq.cleanup` | `expired_count` | int64 | entries dropped for passed `LastLedgerSequence` |
| `txq.accept_tx` | `txq_status` | string | `applied` / `failed` / `retried` |
| `txq.accept_tx` | `ter_code` | string | `transToken(txnResult)` (set before branching on the outcome) |
| `txq.accept` | `ledger_changed` | bool | set at end of accept loop |
| Span | Attribute | Type | Source |
| ----------------- | -------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- |
| `tx.process` | `tx_type` | string | `TxFormats::getInstance().findByType(stx->getTxnType())->getName()` |
| `tx.process` | `fee` | int64 | `stx->getFieldAmount(sfFee).xrp().drops()` |
| `tx.process` | `sequence` | int64 | `stx->getSeqProxy().value()` |
| `tx.process` | `tx_<account field>` | string | one per top-level `STI_ACCOUNT` field, raw r-address (`tx_account`, `tx_destination`, ...); keys in `TxAccountSpanNames.h` |
| `tx.process` | `ter_result` | string | `transToken(e.result)` (set after batch application) |
| `tx.process` | `applied` | bool | `e.applied` (set after batch application) |
| `tx.receive` | `tx_type` | string | `TxFormats::getInstance().findByType(stx->getTxnType())->getName()` |
| `txq.enqueue` | `tx_type` | string | same pattern as above |
| `txq.enqueue` | `txq_status` | string | `queued` / `applied_direct` / `applied` / `failed` / `rejected` |
| `txq.enqueue` | `ter_code` | string | `transToken(directApplied->ter)` (set on the direct-apply path) |
| `txq.enqueue` | `fee_level_paid` | int64 | `getFeeLevelPaid(view, *tx).value()` |
| `txq.enqueue` | `required_fee_level` | int64 | `getRequiredFeeLevel(...).value()` |
| `txq.batch_clear` | `num_cleared` | int64 | queued txs cleared ahead of the applying tx |
| `txq.cleanup` | `expired_count` | int64 | entries dropped for passed `LastLedgerSequence` |
| `txq.accept_tx` | `txq_status` | string | `applied` / `failed` / `retried` |
| `txq.accept_tx` | `ter_code` | string | `transToken(txnResult)` (set before branching on the outcome) |
| `txq.accept` | `ledger_changed` | bool | set at end of accept loop |
**New attr keys**: `TxSpanNames.h` (`txType`, `fee`, `sequence`, `terResult`, `applied`), `TxQSpanNames.h` (`txType`).

View File

@@ -222,7 +222,8 @@ target_link_libraries(
# opentelemetry-cpp::opentelemetry-cpp (individual component targets like
# ::api, ::sdk are not available in the Conan package).
#
# Links xrpl.libxrpl.protocol PRIVATELY for sha512Half (digest.h)
# Links xrpl.libxrpl.protocol PRIVATELY for sha512Half (digest.h) and the
# SField table behind TxAccountSpanNames.cpp
add_module(xrpl telemetry)
target_link_libraries(
xrpl.libxrpl.telemetry

View File

@@ -0,0 +1,172 @@
#pragma once
/**
* Span attribute keys for the account-typed fields of a transaction.
*
* A transaction names one or more accounts: the sender in `Account`, and
* depending on the type a `Destination`, `Owner`, `Issuer`, `Holder` and so
* on. The tx.process span emits every one it finds as its own attribute, so
* an account can be searched for in traces whatever role it played. An
* account address is a public ledger identifier, so each is emitted as the
* raw r-address and never hashed.
*
* One key per protocol field: `tx_` followed by the field's JSON name in
* lower snake case. The full table is the initializer in
* src/libxrpl/telemetry/TxAccountSpanNames.cpp; the common ones are
*
* STTx field span attribute key
* ----------------- ------------------
* Account tx_account
* Destination tx_destination
* Owner tx_owner
* Issuer tx_issuer
* RegularKey tx_regular_key
* NFTokenMinter tx_nftoken_minter
*
* Only fields that some transaction format carries at top level have a key.
* Account-typed fields that appear only in ledger entries or inner objects
* (LowSponsor, LockingChainDoor, ...) map to nullopt. A library test walks
* TxFormats and fails when a format gains an account field with no key.
*
* Why this header lives in libxrpl rather than beside TxSpanNames.h: the
* mapping is keyed by protocol fields and its completeness is checked from
* TxFormats, which a library test can reach and a daemon header cannot.
*
* Data flow:
*
* NetworkOPs::processTransaction (src/xrpld)
* │ for each top-level field with getSType() == STI_ACCOUNT
* ▼
* accountFieldAttributeKey(field.getFName()) (this header)
* │ the key, or nullopt for a field with no key
* ▼
* span->setAttribute(key, field.getText())
*
* @code
* // Primary use: emit every account the transaction names. An empty
* // account field is skipped so it is not rendered as the zero address.
* for (auto const& field : stx)
* {
* if (field.getSType() != STI_ACCOUNT || field.isDefault())
* continue;
* if (auto const key = telemetry::accountFieldAttributeKey(field.getFName()))
* span.setAttribute(*key, toBase58(stx.getAccountID(field.getFName())));
* }
* @endcode
*
* @code
* // Edge case: a field that is not a top-level transaction account has
* // no key, so a caller must test the optional before using it.
* accountFieldAttributeKey(sfFee); // == std::nullopt
* accountFieldAttributeKey(sfLowSponsor); // == std::nullopt
* @endcode
*
* @note Only top-level fields are covered. Accounts nested in Signers, in a
* Batch's inner transactions, or as the issuer inside an Amount are not
* emitted.
* @note accountFieldAttributeKey() is thread-safe. Its table is built once
* on first use and is read-only afterwards.
*/
#include <xrpl/telemetry/SpanNames.h>
#include <optional>
#include <string_view>
namespace xrpl {
class SField;
} // namespace xrpl
namespace xrpl::telemetry {
namespace tx_account_span::attr {
/**
* "tx_account" — the sending account (`Account`). Every transaction has one.
*/
inline constexpr auto account = makeStr("tx_account");
/**
* "tx_destination" — the receiving account (`Destination`).
*/
inline constexpr auto destination = makeStr("tx_destination");
/**
* "tx_owner" — the owner of the object acted on (`Owner`).
*/
inline constexpr auto owner = makeStr("tx_owner");
/**
* "tx_issuer" — the issuer named by the transaction (`Issuer`).
*/
inline constexpr auto issuer = makeStr("tx_issuer");
/**
* "tx_authorize" — the account being authorised (`Authorize`).
*/
inline constexpr auto authorize = makeStr("tx_authorize");
/**
* "tx_unauthorize" — the account whose authorisation is removed (`Unauthorize`).
*/
inline constexpr auto unauthorize = makeStr("tx_unauthorize");
/**
* "tx_regular_key" — the regular key being set (`RegularKey`).
*/
inline constexpr auto regularKey = makeStr("tx_regular_key");
/**
* "tx_nftoken_minter" — the authorised NFToken minter (`NFTokenMinter`).
*/
inline constexpr auto nftokenMinter = makeStr("tx_nftoken_minter");
/**
* "tx_holder" — the token holder acted on (`Holder`).
*/
inline constexpr auto holder = makeStr("tx_holder");
/**
* "tx_delegate" — the delegate signing on the sender's behalf (`Delegate`).
*/
inline constexpr auto delegate = makeStr("tx_delegate");
/**
* "tx_sponsor" — the account paying the fee or reserve (`Sponsor`).
*/
inline constexpr auto sponsor = makeStr("tx_sponsor");
/**
* "tx_sponsee" — the account being sponsored (`Sponsee`).
*/
inline constexpr auto sponsee = makeStr("tx_sponsee");
/**
* "tx_counterparty" — the other party to a loan (`Counterparty`).
*/
inline constexpr auto counterparty = makeStr("tx_counterparty");
/**
* "tx_counterparty_sponsor" — the counterparty's sponsor (`CounterpartySponsor`).
*/
inline constexpr auto counterpartySponsor = makeStr("tx_counterparty_sponsor");
/**
* "tx_subject" — the subject of a credential (`Subject`).
*/
inline constexpr auto subject = makeStr("tx_subject");
/**
* "tx_other_chain_source" — the source account on the other chain (`OtherChainSource`).
*/
inline constexpr auto otherChainSource = makeStr("tx_other_chain_source");
/**
* "tx_other_chain_destination" — destination on the other chain (`OtherChainDestination`).
*/
inline constexpr auto otherChainDestination = makeStr("tx_other_chain_destination");
/**
* "tx_attestation_signer_account" — the attestation signer (`AttestationSignerAccount`).
*/
inline constexpr auto attestationSignerAccount = makeStr("tx_attestation_signer_account");
/**
* "tx_attestation_reward_account" — attestation reward account (`AttestationRewardAccount`).
*/
inline constexpr auto attestationRewardAccount = makeStr("tx_attestation_reward_account");
} // namespace tx_account_span::attr
/**
* Look up the span attribute key for an account-typed transaction field.
*
* @param field The protocol field, as returned by STBase::getFName().
* @return The `tx_*` key for a top-level transaction account field, or
* nullopt when the field is not account-typed or is carried only by ledger
* entries and inner objects.
*/
[[nodiscard]] std::optional<std::string_view>
accountFieldAttributeKey(SField const& field);
} // namespace xrpl::telemetry

View File

@@ -0,0 +1,45 @@
#include <xrpl/telemetry/TxAccountSpanNames.h>
#include <xrpl/protocol/SField.h>
#include <optional>
#include <string_view>
#include <unordered_map>
namespace xrpl::telemetry {
std::optional<std::string_view>
accountFieldAttributeKey(SField const& field)
{
// Built on first call, not at static initialisation: the sf* objects are
// globals in another translation unit, so their codes are only safe to
// read once main() has started.
static std::unordered_map<int, std::string_view> const kTable = {
{sfAccount.getCode(), tx_account_span::attr::account},
{sfDestination.getCode(), tx_account_span::attr::destination},
{sfOwner.getCode(), tx_account_span::attr::owner},
{sfIssuer.getCode(), tx_account_span::attr::issuer},
{sfAuthorize.getCode(), tx_account_span::attr::authorize},
{sfUnauthorize.getCode(), tx_account_span::attr::unauthorize},
{sfRegularKey.getCode(), tx_account_span::attr::regularKey},
{sfNFTokenMinter.getCode(), tx_account_span::attr::nftokenMinter},
{sfHolder.getCode(), tx_account_span::attr::holder},
{sfDelegate.getCode(), tx_account_span::attr::delegate},
{sfSponsor.getCode(), tx_account_span::attr::sponsor},
{sfSponsee.getCode(), tx_account_span::attr::sponsee},
{sfCounterparty.getCode(), tx_account_span::attr::counterparty},
{sfCounterpartySponsor.getCode(), tx_account_span::attr::counterpartySponsor},
{sfSubject.getCode(), tx_account_span::attr::subject},
{sfOtherChainSource.getCode(), tx_account_span::attr::otherChainSource},
{sfOtherChainDestination.getCode(), tx_account_span::attr::otherChainDestination},
{sfAttestationSignerAccount.getCode(), tx_account_span::attr::attestationSignerAccount},
{sfAttestationRewardAccount.getCode(), tx_account_span::attr::attestationRewardAccount},
};
auto const it = kTable.find(field.getCode());
if (it == kTable.end())
return std::nullopt;
return it->second;
}
} // namespace xrpl::telemetry

View File

@@ -0,0 +1,163 @@
#include <xrpl/telemetry/TxAccountSpanNames.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TxFormats.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <cctype>
#include <optional>
#include <set>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
/**
* Contract tests for the account-field attribute keys of the tx.process span.
*
* The key set is a cross-component contract: Tempo span-filter tags, the
* naming CI check and dashboards read these exact strings. A transaction type
* that gains an account-typed field without a key would silently emit nothing
* for it, so the completeness tests derive the required set from TxFormats
* and from the SField registry rather than from a copied list.
*/
using namespace xrpl;
using namespace xrpl::telemetry;
namespace {
// Every account-typed field that any transaction format carries at top level,
// including the common fields shared by all formats.
std::set<SField const*>
accountFieldsInTransactionFormats()
{
std::set<SField const*> fields;
for (auto const& format : TxFormats::getInstance())
{
for (auto const& element : format.getSOTemplate())
{
if (element.sField().fieldType == STI_ACCOUNT)
fields.insert(&element.sField());
}
}
return fields;
}
// Every account-typed field the protocol defines, whether or not a transaction
// carries it.
std::set<SField const*>
allAccountFields()
{
std::set<SField const*> fields;
for (auto const& [code, field] : SField::getKnownCodeToField())
{
if (field->fieldType == STI_ACCOUNT)
fields.insert(field);
}
return fields;
}
} // namespace
TEST(TxAccountSpanNames, every_account_field_a_transaction_can_carry_has_a_key)
{
auto const fields = accountFieldsInTransactionFormats();
// Setup: the walk over TxFormats found the one field every transaction has.
ASSERT_TRUE(fields.contains(&sfAccount));
for (auto const* field : fields)
{
EXPECT_TRUE(accountFieldAttributeKey(*field).has_value())
<< "no attribute key for " << field->getName();
}
}
TEST(TxAccountSpanNames, account_fields_no_transaction_carries_have_no_key)
{
auto const carried = accountFieldsInTransactionFormats();
auto const all = allAccountFields();
// Setup: the registry holds more account fields than transactions carry.
ASSERT_TRUE(all.contains(&sfLowSponsor));
ASSERT_FALSE(carried.contains(&sfLowSponsor));
for (auto const* field : all)
{
if (!carried.contains(field))
EXPECT_EQ(accountFieldAttributeKey(*field), std::nullopt) << field->getName();
}
}
TEST(TxAccountSpanNames, every_key_is_tx_plus_the_field_name_in_lower_snake_case)
{
for (auto const* field : accountFieldsInTransactionFormats())
{
auto const maybeKey = accountFieldAttributeKey(*field);
ASSERT_TRUE(maybeKey.has_value()) << field->getName();
auto const key = maybeKey.value_or(std::string_view{});
// Shape: tx_ prefix, then lower_snake_case with no empty segment.
EXPECT_TRUE(key.starts_with("tx_")) << key;
EXPECT_FALSE(key.ends_with('_')) << key;
EXPECT_EQ(key.find("__"), std::string_view::npos) << key;
EXPECT_TRUE(std::ranges::all_of(key, [](unsigned char c) {
return std::islower(c) != 0 || std::isdigit(c) != 0 || c == '_';
})) << key;
// Content: the key with prefix and underscores removed is the field's
// JSON name lowercased. Catches a misspelt or swapped key.
std::string flattened(key.substr(3));
std::erase(flattened, '_');
std::string lowered = field->getName();
std::ranges::transform(lowered, lowered.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
EXPECT_EQ(flattened, lowered) << key;
}
}
// The published contract: every carried field and its exact key. Literals are
// deliberate here; the point is to pin underscore placement, which the shape
// test above cannot see.
TEST(TxAccountSpanNames, exact_key_for_every_carried_field)
{
std::vector<std::pair<SField const*, std::string_view>> const expected = {
{&sfAccount, "tx_account"},
{&sfDestination, "tx_destination"},
{&sfOwner, "tx_owner"},
{&sfIssuer, "tx_issuer"},
{&sfAuthorize, "tx_authorize"},
{&sfUnauthorize, "tx_unauthorize"},
{&sfRegularKey, "tx_regular_key"},
{&sfNFTokenMinter, "tx_nftoken_minter"},
{&sfHolder, "tx_holder"},
{&sfDelegate, "tx_delegate"},
{&sfSponsor, "tx_sponsor"},
{&sfSponsee, "tx_sponsee"},
{&sfCounterparty, "tx_counterparty"},
{&sfCounterpartySponsor, "tx_counterparty_sponsor"},
{&sfSubject, "tx_subject"},
{&sfOtherChainSource, "tx_other_chain_source"},
{&sfOtherChainDestination, "tx_other_chain_destination"},
{&sfAttestationSignerAccount, "tx_attestation_signer_account"},
{&sfAttestationRewardAccount, "tx_attestation_reward_account"},
};
// Setup: this list and the TxFormats walk must name the same fields, or a
// row is missing here.
std::set<SField const*> listed;
for (auto const& [field, key] : expected)
listed.insert(field);
ASSERT_EQ(listed, accountFieldsInTransactionFormats());
for (auto const& [field, key] : expected)
EXPECT_EQ(accountFieldAttributeKey(*field).value_or(""), key) << field->getName();
}
TEST(TxAccountSpanNames, non_account_fields_have_no_key)
{
EXPECT_EQ(accountFieldAttributeKey(sfFee), std::nullopt);
EXPECT_EQ(accountFieldAttributeKey(sfSequence), std::nullopt);
EXPECT_EQ(accountFieldAttributeKey(sfInvalid), std::nullopt);
}

View File

@@ -119,6 +119,7 @@
#include <xrpl/server/Manifest.h>
#include <xrpl/shamap/SHAMap.h>
#include <xrpl/telemetry/SpanGuard.h>
#include <xrpl/telemetry/TxAccountSpanNames.h>
#include <xrpl/tx/apply.h>
#include <boost/asio/error.hpp>
@@ -1571,6 +1572,18 @@ NetworkOPsImp::processTransaction(
}
span->setAttribute(
tx_span::attr::sequence, static_cast<std::int64_t>(stx->getSeqProxy().value()));
// Every account the transaction names, keyed by its role
// (tx_account, tx_destination, ...). Addresses are public ledger
// identifiers and go out raw; see TxAccountSpanNames.h. A present
// but empty account field is skipped rather than rendered as the
// all-zero address.
for (auto const& field : *stx)
{
if (field.getSType() != STI_ACCOUNT || field.isDefault())
continue;
if (auto const key = accountFieldAttributeKey(field.getFName()))
span->setAttribute(*key, field.getText());
}
}
}

View File

@@ -85,6 +85,9 @@ inline constexpr auto fee = makeStr("fee");
* "sequence" — transaction sequence number.
*/
inline constexpr auto sequence = makeStr("sequence");
// The per-role account keys (tx_account, tx_destination, ...) that tx.process
// also carries live in <xrpl/telemetry/TxAccountSpanNames.h>, in libxrpl, so a
// library test can check them against TxFormats.
/**
* "ter_result" — engine result code after application.
*/