From 509fc7853e7e812f8c505ece997bbc1ff222b557 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:01:14 +0100 Subject: [PATCH] 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_ 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. --- OpenTelemetryPlan/Phase3_taskList.md | 37 ++-- cmake/XrplCore.cmake | 3 +- include/xrpl/telemetry/TxAccountSpanNames.h | 172 ++++++++++++++++++ src/libxrpl/telemetry/TxAccountSpanNames.cpp | 45 +++++ .../libxrpl/telemetry/TxAccountSpanNames.cpp | 163 +++++++++++++++++ src/xrpld/app/misc/NetworkOPs.cpp | 13 ++ src/xrpld/telemetry/TxSpanNames.h | 3 + 7 files changed, 417 insertions(+), 19 deletions(-) create mode 100644 include/xrpl/telemetry/TxAccountSpanNames.h create mode 100644 src/libxrpl/telemetry/TxAccountSpanNames.cpp create mode 100644 src/tests/libxrpl/telemetry/TxAccountSpanNames.cpp diff --git a/OpenTelemetryPlan/Phase3_taskList.md b/OpenTelemetryPlan/Phase3_taskList.md index 601e0bd047..1652d3c89b 100644 --- a/OpenTelemetryPlan/Phase3_taskList.md +++ b/OpenTelemetryPlan/Phase3_taskList.md @@ -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_` | 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`). diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake index c9d392cfcc..ed619c286f 100644 --- a/cmake/XrplCore.cmake +++ b/cmake/XrplCore.cmake @@ -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 diff --git a/include/xrpl/telemetry/TxAccountSpanNames.h b/include/xrpl/telemetry/TxAccountSpanNames.h new file mode 100644 index 0000000000..fd6560a9f7 --- /dev/null +++ b/include/xrpl/telemetry/TxAccountSpanNames.h @@ -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 + +#include +#include + +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 +accountFieldAttributeKey(SField const& field); + +} // namespace xrpl::telemetry diff --git a/src/libxrpl/telemetry/TxAccountSpanNames.cpp b/src/libxrpl/telemetry/TxAccountSpanNames.cpp new file mode 100644 index 0000000000..71318f4195 --- /dev/null +++ b/src/libxrpl/telemetry/TxAccountSpanNames.cpp @@ -0,0 +1,45 @@ +#include + +#include + +#include +#include +#include + +namespace xrpl::telemetry { + +std::optional +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 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 diff --git a/src/tests/libxrpl/telemetry/TxAccountSpanNames.cpp b/src/tests/libxrpl/telemetry/TxAccountSpanNames.cpp new file mode 100644 index 0000000000..0b51776647 --- /dev/null +++ b/src/tests/libxrpl/telemetry/TxAccountSpanNames.cpp @@ -0,0 +1,163 @@ +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * 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 +accountFieldsInTransactionFormats() +{ + std::set 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 +allAccountFields() +{ + std::set 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(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> 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 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); +} diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index e4c16bd85c..17fead873e 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -119,6 +119,7 @@ #include #include #include +#include #include #include @@ -1571,6 +1572,18 @@ NetworkOPsImp::processTransaction( } span->setAttribute( tx_span::attr::sequence, static_cast(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()); + } } } diff --git a/src/xrpld/telemetry/TxSpanNames.h b/src/xrpld/telemetry/TxSpanNames.h index 1b54f3371b..64049da6c9 100644 --- a/src/xrpld/telemetry/TxSpanNames.h +++ b/src/xrpld/telemetry/TxSpanNames.h @@ -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 , in libxrpl, so a +// library test can check them against TxFormats. /** * "ter_result" — engine result code after application. */