Merge branch 'pratik/otel-phase4-consensus-tracing' into pratik/otel-phase5-docs-deployment

This commit is contained in:
Pratik Mankawde
2026-09-22 21:15:16 +01:00
15 changed files with 420 additions and 360 deletions

View File

@@ -0,0 +1,176 @@
#include <test/jtx/Env.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/rpc/Context.h>
#include <xrpld/rpc/RPCHandler.h>
#include <xrpld/rpc/Role.h>
#include <xrpld/rpc/Status.h>
#include <xrpld/rpc/detail/Tuning.h>
#include <xrpl/basics/scope.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/core/Job.h>
#include <xrpl/core/JobQueue.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/ApiVersion.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/resource/Charge.h>
#include <xrpl/resource/Consumer.h>
#include <xrpl/resource/Fees.h>
#include <exception>
#include <future>
#include <string>
namespace xrpl::test {
/**
* Checks the error a busy server reports for a request it never dispatches.
*
* RPCHandler_test ──doCommand()──> RPC::fillHandler()
* │ │
* └── fills ──> JobQueue <── reads ─┘
*
* An overloaded server answers rpcTOO_BUSY before it reads the command name, so
* the request fields still hold whatever json type the client sent. Anything
* that runs afterwards to describe the error has to cope with that and leave
* the answer alone.
*
* @note Each testcase keeps one job-queue worker blocked for as long as it
* runs, and releases it before returning.
*/
class RPCHandler_test : public beast::unit_test::Suite
{
/**
* How many jobs to queue to hold the server over its overload threshold.
* One job is dispatched straight away, so one spare keeps the waiting
* count above the limit.
*/
static constexpr int kOverloadJobs = rpc::tuning::kMaxJobQueueClients + 2;
/**
* Dispatches one request on an overloaded server and checks the client is
* told the server is busy.
*
* @param params Request fields, in the form fillHandler() reads them.
*/
void
expectTooBusy(json::Value const& params)
{
using namespace jtx;
Env env{*this};
auto& app = env.app();
// Only one job of this type runs at a time, so every job after the
// first stays queued until the gate opens. They also sort above
// JtClient, the priority the overload check counts from.
std::promise<void> gate;
std::shared_future<void> const open = gate.get_future().share();
ScopeExit const openGate{[&gate]() { gate.set_value(); }};
int queued = 0;
for (int i = 0; i < kOverloadJobs; ++i)
{
if (app.getJobQueue().addJob(JtSweep, "overload", [open]() { open.wait(); }))
++queued;
}
BEAST_EXPECT(queued == kOverloadJobs);
BEAST_EXPECT(app.getJobQueue().getJobCountGE(JtClient) > rpc::tuning::kMaxJobQueueClients);
resource::Charge loadType = resource::kFeeReferenceRpc;
resource::Consumer consumer;
rpc::JsonContext context{
{.j = env.journal,
.app = app,
.loadType = loadType,
.netOps = app.getOPs(),
.ledgerMaster = app.getLedgerMaster(),
.consumer = consumer,
.role = Role::USER,
.coro = {},
.infoSub = {},
.apiVersion = rpc::kApiVersionIfUnspecified},
params,
{}};
json::Value result;
rpc::Status status;
std::string thrown;
try
{
status = rpc::doCommand(context, result);
}
catch (std::exception const& e)
{
thrown = e.what();
}
if (BEAST_EXPECTS(thrown.empty(), "doCommand threw: " + thrown))
{
BEAST_EXPECT(status.type() == rpc::Status::Type::ErrorCodeI);
BEAST_EXPECT(status.toErrorCode() == RpcTooBusy);
BEAST_EXPECT(result[jss::error].asString() == "tooBusy");
BEAST_EXPECT(result[jss::error_code].asInt() == static_cast<int>(RpcTooBusy));
}
}
/**
* Checks a well-formed request on an overloaded server. This is the control
* for the two cases below: it shares their fixture and their assertions,
* and differs only in that every field it sends is a string.
*/
void
testRegisteredCommand()
{
testcase("Busy server, registered command");
json::Value params = json::ValueType::Object;
params[jss::command] = "ping";
expectTooBusy(params);
}
/**
* Checks a request whose "method" field is not a string.
*/
void
testNonStringMethod()
{
testcase("Busy server, method field is not a string");
// The HTTP path sets "command" from the outer method name it has
// already checked, and passes the inner request object through
// untouched, so "method" can arrive holding any json type.
json::Value params = json::ValueType::Object;
params[jss::command] = "ping";
params[jss::method] = json::ValueType::Array;
expectTooBusy(params);
}
/**
* Checks a request whose "command" field is not a string.
*/
void
testNonStringCommand()
{
testcase("Busy server, command field is not a string");
json::Value params = json::ValueType::Object;
params[jss::command] = json::ValueType::Object;
expectTooBusy(params);
}
public:
void
run() override
{
testRegisteredCommand();
testNonStringMethod();
testNonStringCommand();
}
};
BEAST_DEFINE_TESTSUITE(RPCHandler, rpc, xrpl);
} // namespace xrpl::test

View File

@@ -1,12 +1,14 @@
#include <xrpl/consensus/ConsensusSpanNames.h>
#include <xrpl/telemetry/SpanGuard.h>
#include <xrpl/telemetry/SpanNames.h>
#include <xrpl/telemetry/Telemetry.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <exception>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
using namespace xrpl;
@@ -88,28 +90,44 @@ TEST(SpanGuardFactory, discard_safe_on_null)
EXPECT_FALSE(span);
}
TEST(SpanGuardFactory, consensus_close_time_attributes)
TEST(SpanGuardFactory, consensus_accept_apply_attributes_are_inert_on_null_guard)
{
// Verify the consensus attribute pattern compiles and doesn't crash with a
// null SpanGuard. Attribute keys/values use the underscore convention; the
// canonical consensus::span constants are defined in the xrpld-level
// ConsensusSpanNames.h, which a libxrpl test cannot include, so the keys are
// written as literals here.
{
auto span = telemetry::SpanGuard::span(
telemetry::TraceCategory::Consensus, telemetry::seg::consensus, "accept.apply");
span.setAttribute("ledger_seq", static_cast<int64_t>(42));
span.setAttribute("close_time_ripple_epoch_s", static_cast<int64_t>(780000000));
span.setAttribute("close_time_correct", true);
span.setAttribute("close_resolution_ms", static_cast<int64_t>(30000));
span.setAttribute("consensus_state", std::string("finished"));
span.setAttribute("proposing", true);
span.setAttribute("round_time_ms", static_cast<int64_t>(3500));
}
{
auto span = telemetry::SpanGuard::span(
telemetry::TraceCategory::Consensus, telemetry::seg::consensus, "accept.apply");
span.setAttribute("close_time_correct", false);
span.setAttribute("consensus_state", std::string("moved_on"));
}
namespace cs = consensus::span;
// Nothing in this binary starts telemetry, so span() returns a null guard
// before it even joins the name. Pinning that here says which of the
// factory's exits produced the null guard the rest of the test relies on.
ASSERT_EQ(Telemetry::getInstance(), nullptr);
// The attribute set RCLConsensus::doAccept() writes on consensus.accept.apply,
// read from the same constants the emitter uses rather than copied as
// literals. Both close-time outcomes are written below: the values differ,
// the guard's inertness does not.
auto applySpan = SpanGuard::span(TraceCategory::Consensus, seg::consensus, cs::op::acceptApply);
ASSERT_FALSE(applySpan);
applySpan.setAttribute(cs::attr::ledgerSeq, static_cast<std::int64_t>(42));
applySpan.setAttribute(cs::attr::closeTimeRippleEpochS, static_cast<std::int64_t>(780000000));
applySpan.setAttribute(cs::attr::closeTimeCorrect, true);
applySpan.setAttribute(cs::attr::closeResolutionMs, static_cast<std::int64_t>(30000));
applySpan.setAttribute(cs::attr::consensusState, std::string_view{cs::val::finished});
applySpan.setAttribute(cs::attr::proposing, true);
applySpan.setAttribute(cs::attr::roundTimeMs, static_cast<std::int64_t>(3500));
// A write cannot activate a guard, so it still holds no span and hands out
// no propagation bytes for an outgoing message to carry.
EXPECT_FALSE(applySpan);
EXPECT_FALSE(applySpan.getTraceBytes().valid);
// The consensus-failed branch writes the other value over the same two keys,
// and reaches the same inert guard.
auto movedOnSpan =
SpanGuard::span(TraceCategory::Consensus, seg::consensus, cs::op::acceptApply);
ASSERT_FALSE(movedOnSpan);
movedOnSpan.setAttribute(cs::attr::closeTimeCorrect, false);
movedOnSpan.setAttribute(cs::attr::consensusState, std::string_view{cs::val::movedOn});
EXPECT_FALSE(movedOnSpan);
EXPECT_FALSE(movedOnSpan.getTraceBytes().valid);
}

View File

@@ -27,6 +27,7 @@
#ifdef XRPL_ENABLE_TELEMETRY
#include <xrpl/basics/LocalValue.h>
#include <xrpl/basics/scope.h>
#include <xrpl/consensus/ConsensusSpanNames.h>
#include <xrpl/telemetry/CoroAwareContextStorage.h>
#include <xrpl/telemetry/DeterministicIdGenerator.h>
@@ -509,9 +510,15 @@ TEST_F(SpanGuardScopeTest, scopedGuard_survives_localvalue_store_swap)
xrpl::detail::LocalValues coroStore;
xrpl::detail::LocalValues workerStore;
// Detach (do NOT delete) the fixture's active store, run on the coro store,
// and remember the original so teardown gets it back.
// Detach (do NOT delete) the fixture's active store and run on the coro
// store. A failed ASSERT_* returns from the test body, so the restore must be
// RAII or the thread pointer keeps owning a stack store that is about to die.
// Declared after both stack stores, so it is destroyed before either of them.
auto* saved = xrpl::detail::getLocalValues().release();
xrpl::ScopeExit const restoreStore{[saved]() {
xrpl::detail::getLocalValues().release();
xrpl::detail::getLocalValues().reset(saved);
}};
xrpl::detail::getLocalValues().reset(&coroStore);
trc::SpanContext captured = trc::SpanContext::GetInvalid();
@@ -541,11 +548,9 @@ TEST_F(SpanGuardScopeTest, scopedGuard_survives_localvalue_store_swap)
auto afterPop = trc::GetSpan(ctx::RuntimeContext::GetCurrent());
EXPECT_FALSE(afterPop->GetContext().IsValid());
// Restore (re-own) the fixture's store for teardown before any stack store
// leaves scope, so the thread pointer never dangles.
xrpl::detail::getLocalValues().release();
xrpl::detail::getLocalValues().reset(saved);
// restoreStore re-owns the fixture's store from here on: it runs on every
// exit path, and the checks below touch no LocalValue.
//
// The span ended exactly once, when the scope popped on resume.
EXPECT_EQ(countSpans(spanData()->GetSpans(), "rpc.process"), 1u);
}

View File

@@ -280,15 +280,16 @@ RCLConsensus::Adaptor::propose(RCLCxPeerPos::Proposal const& proposal)
app_.getHashRouter().addSuppression(suppression);
// Inject the current thread's active span context (e.g. the consensus
// round span) so receiving peers can link their proposal.receive span
// as a child of this trace.
// Inject this send span's own context, so receiving peers can parent their
// proposal.receive span to it. Reading the ambient context instead would
// find nothing: no span is activated on either thread that reaches here,
// and the round span is deliberately never ambient.
//
// The helper injects only when a span is actually active, so a node with
// telemetry compiled out, disabled by config, or simply not tracing this
// round sends no TraceContext at all rather than an empty one that makes
// every peer take its has_trace_context() branch for nothing.
telemetry::injectCurrentContext(prop);
// Injection writes only when the span is live, so a node with telemetry
// compiled out, disabled by config, or simply not tracing this round sends
// no TraceContext at all rather than an empty one that makes every peer
// take its has_trace_context() branch for nothing.
telemetry::injectSpanContext(span, prop);
app_.getOverlay().broadcast(prop);
}
@@ -1109,18 +1110,20 @@ RCLConsensus::Adaptor::validate(RCLCxLedger const& ledger, RCLTxSet const& txns,
// Broadcast to all our peers:
protocol::TMValidation val;
val.set_validation(serialized.data(), serialized.size());
// Inject the current thread's active span context so receiving
// peers can link their validation.receive span as a child.
// Inject this validation span's own context, so receiving peers can parent
// their validation.receive span to it. Reading the ambient context instead
// would find nothing: valSpan is parented through a stored context and is
// never activated on this thread.
//
// The trace_context appended below is outside the signature on
// `serialized`, so it is not covered by validation authenticity.
// Downstream consumers treat it as advisory only. A signature-covered
// trace context is a possible future enhancement.
//
// As on the proposal path, the helper injects only when a span is actually
// active, so a node that is not tracing sends no TraceContext at all
// rather than an empty one.
telemetry::injectCurrentContext(val);
// Injection writes only when the span is live, so a node that is not
// tracing sends no TraceContext at all rather than an empty one.
if (valSpan)
telemetry::injectSpanContext(*valSpan, val);
app_.getOverlay().broadcast(val);
// Publish to all our subscribers:

View File

@@ -29,6 +29,10 @@
* | +-----------------------------------------------------------+ |
* +----------------------------------------------------------------+
*
* pathfind.request ends with status error whenever the handler's reply
* carries an rpc error. The description is that error's registry token,
* never request text.
*
* Async recomputation (ledger close):
*
* +----------------------------------------------------------------+

View File

@@ -158,6 +158,42 @@ fillHandler(JsonContext& context, Handler const*& result)
return RpcSuccess;
}
/**
* Names the reason a command failed, for the span's error description.
*
* jss::error holds the error token, and an old-style handler reports it there
* and nowhere else. A failure the reply does not name is described by the
* status's own error code, which is the only reason left to report. Every
* token is a compile-time string, so no request text reaches the description.
*
* @param status What the handler returned.
* @param result The reply the handler filled in. The returned view can point
* into it, so result must outlive the view.
* @param replyHasError The caller's containsError(result), passed in so the
* reply is not searched twice.
* @return The error token, or "error" where neither source carries one.
*/
std::string_view
errorDescription(Status const& status, json::Value const& result, bool replyHasError)
{
if (replyHasError && result[jss::error].isString())
{
// asCString() asserts the type, then hands back the stored pointer
// unchecked, and a string-typed json::Value may hold a null one. Both
// checks are needed before that pointer becomes a view.
if (char const* const token = result[jss::error].asCString(); token != nullptr)
return token;
}
// A TER or a bare integer code has no token in the error registry, so
// reading one would name an unrelated error. getErrorInfo() returns a
// reference into a static table, so its token outlives this call.
if (status.type() == Status::Type::ErrorCodeI)
return getErrorInfo(status.toErrorCode()).token.cStr();
return rpc_span::val::error;
}
Status
callMethod(JsonContext& context, Handler::Method method, std::string_view name, json::Value& result)
{
@@ -190,23 +226,33 @@ callMethod(JsonContext& context, Handler::Method method, std::string_view name,
JLOG(context.j.debug()) << "RPC call " << name << " completed in "
<< ((end - start).count() / 1000000000.0) << "seconds";
perfLog.rpcFinish(name, curId);
span.setAttribute(rpc_span::attr::loadType, context.loadType.label().c_str());
// Status::operator bool() returns true when there IS an error
// (code_ != OK), so the ternary correctly maps error->error, ok->success.
span.setAttribute(
rpc_span::attr::rpcStatus,
ret ? std::string_view{rpc_span::val::error}
: std::string_view{rpc_span::val::success});
// Reflect the result in the OTel span status, not just the attribute,
// so non-exception RPC errors (rpcTOO_BUSY, rpcNO_PERMISSION, ...) are
// visible to {status.code=error} queries.
if (ret)
// Everything in here only feeds the span, and searching the reply is
// not free, so a null guard pays for none of it. setError() and
// setAttribute() are no-ops on a null guard, but their arguments are
// not: with telemetry compiled out operator bool() is a constant false.
if (span)
{
span.setError(rpc_span::val::error);
}
else
{
span.setOk();
// Read after the handler ran, because a handler may raise its own
// load type (pathfind charges a heavy burden).
span.setAttribute(rpc_span::attr::loadType, context.loadType.label().c_str());
// An old-style handler reports its error in the reply, not in the
// Status: byRef() returns a default Status whatever happened.
// Reading both covers every handler. Status::operator bool() is
// true when there IS an error.
bool const replyHasError = containsError(result);
bool const failed = static_cast<bool>(ret) || replyHasError;
// Two values only. rpc_status is a spanmetrics dimension, so every
// value it can take becomes a Prometheus label and a metric series.
span.setAttribute(
rpc_span::attr::rpcStatus,
failed ? std::string_view{rpc_span::val::error}
: std::string_view{rpc_span::val::success});
// Error so a failed call answers {status.code=error}, for the codes
// that never throw (rpcTOO_BUSY, rpcNO_PERMISSION, ...). Success
// stays Unset: the spec reserves Ok for an operator asserting
// verified success, and a tool may read it as suppressing errors.
if (failed)
span.setError(errorDescription(ret, result, replyHasError));
}
return ret;
}
@@ -234,32 +280,37 @@ callMethod(JsonContext& context, Handler::Method method, std::string_view name,
#ifdef XRPL_ENABLE_TELEMETRY
// Resolve the span suffix / command attribute for a request that failed in
// fillHandler. Returns the canonical handler name for a recognized command
// (a finite, bounded set) or the literal "unknown" for a request that omits
// both fields or names an unregistered command. The raw request value is
// deliberately NOT used: the command attribute is promoted to a Prometheus
// label by the spanmetrics connector, so an attacker-controlled string would
// let arbitrary request input drive unbounded span-name / label cardinality.
// Resolving against the registry keeps per-command error attribution for real
// commands (e.g. a submit rejected with rpcTOO_BUSY stays rpc.command.submit)
// while collapsing garbage input to a single series.
// fillHandler. The name comes from the handler registry, so only a registered
// handler name or the "unknown" label can reach the span; request text never
// does. That bounded set also bounds the Prometheus label the spanmetrics
// connector derives from it, and a real command still keeps its own error
// attribution: a submit rejected with rpcTOO_BUSY stays rpc.command.submit.
std::string_view
resolveCommandSpanName(JsonContext const& context)
{
if (!context.params.isMember(jss::command) && !context.params.isMember(jss::method))
bool const hasCommand = context.params.isMember(jss::command);
bool const hasMethod = context.params.isMember(jss::method);
if (!hasCommand && !hasMethod)
return rpc_span::val::unknownCommand;
// A json array or object throws when asked for its string value, and no
// non-string field names a handler. The reply's error code is already
// decided, so naming the span must not be able to change it.
if ((hasCommand && !context.params[jss::command].isString()) ||
(hasMethod && !context.params[jss::method].isString()))
return rpc_span::val::unknownCommand;
// fillHandler() rejects a request that supplies both fields with differing
// values as rpcUNKNOWN_COMMAND. Mirror that here, or the span would be
// labelled with one of the two names and misattribute the error to a
// command that was never dispatched.
if (context.params.isMember(jss::command) && context.params.isMember(jss::method) &&
if (hasCommand && hasMethod &&
context.params[jss::command].asString() != context.params[jss::method].asString())
return rpc_span::val::unknownCommand;
std::string const cmd = context.params.isMember(jss::command)
? context.params[jss::command].asString()
: context.params[jss::method].asString();
std::string const cmd = hasCommand ? context.params[jss::command].asString()
: context.params[jss::method].asString();
auto const* handler = getHandler(context.apiVersion, context.app.config().betaRpcApi, cmd);
return (handler != nullptr) ? std::string_view{handler->name}

View File

@@ -391,6 +391,10 @@ ServerHandler::onWSMessage(
// Fresh root so each WS message is its own trace.
auto span = ScopedSpanGuard::freshRoot(
TraceCategory::Rpc, rpc_span::prefix::rpc, rpc_span::op::wsMessage);
// rpc_status is a span-metrics dimension, so leaving it unset emits a
// series with a blank label and hides this failure from any query that
// selects on error.
span.setAttribute(rpc_span::attr::rpcStatus, rpc_span::val::error);
span.setError(rpc_span::val::invalidJson);
json::Value jvResult(json::ValueType::Object);

View File

@@ -47,18 +47,28 @@ doPathFind(rpc::JsonContext& context)
span.setAttribute(pathfind_span::attr::destAccount, redactAccount(dst.asString()));
}
// A failed reply carries the rpc error token, so reading the status off the
// reply covers every exit, including the ones whose reply is built further
// down the call chain. The token set is fixed by the error registry, so it
// is safe as a span label; raw request text would not be.
auto const finish = [&span](json::Value&& reply) -> json::Value {
if (span && rpc::containsError(reply))
span.setError(std::as_const(reply)[jss::error].asString());
return std::move(reply);
};
if (context.app.config().pathSearchMax == 0)
return rpcError(RpcNotSupported);
return finish(rpcError(RpcNotSupported));
auto lpLedger = context.ledgerMaster.getClosedLedger();
if (!context.params.isMember(jss::subcommand) || !context.params[jss::subcommand].isString())
{
return rpcError(RpcInvalidParams);
return finish(rpcError(RpcInvalidParams));
}
if (!context.infoSub)
return rpcError(RpcNoEvents);
return finish(rpcError(RpcNoEvents));
context.infoSub->setApiVersion(context.apiVersion);
@@ -68,8 +78,8 @@ doPathFind(rpc::JsonContext& context)
{
context.loadType = resource::kFeeHeavyBurdenRpc;
context.infoSub->clearRequest();
return context.app.getPathRequestManager().makePathRequest(
context.infoSub, lpLedger, context.params);
return finish(context.app.getPathRequestManager().makePathRequest(
context.infoSub, lpLedger, context.params));
}
if (sSubCommand == "close")
@@ -77,10 +87,10 @@ doPathFind(rpc::JsonContext& context)
InfoSubRequest::pointer const request = context.infoSub->getRequest();
if (!request)
return rpcError(RpcNoPfRequest);
return finish(rpcError(RpcNoPfRequest));
context.infoSub->clearRequest();
return request->doClose();
return finish(request->doClose());
}
if (sSubCommand == "status")
@@ -88,12 +98,12 @@ doPathFind(rpc::JsonContext& context)
InfoSubRequest::pointer const request = context.infoSub->getRequest();
if (!request)
return rpcError(RpcNoPfRequest);
return finish(rpcError(RpcNoPfRequest));
return request->doStatus(context.params);
return finish(request->doStatus(context.params));
}
return rpcError(RpcInvalidParams);
return finish(rpcError(RpcInvalidParams));
}
} // namespace xrpl

View File

@@ -56,8 +56,18 @@ doRipplePathFind(rpc::JsonContext& context)
span.setAttribute(pathfind_span::attr::destAccount, redactAccount(dst.asString()));
}
// A failed reply carries the rpc error token, so reading the status off the
// reply covers every exit, including the ones whose reply is built further
// down the call chain. The token set is fixed by the error registry, so it
// is safe as a span label; raw request text would not be.
auto const finish = [&span](json::Value&& reply) -> json::Value {
if (span && rpc::containsError(reply))
span.setError(std::as_const(reply)[jss::error].asString());
return std::move(reply);
};
if (context.app.config().pathSearchMax == 0)
return rpcError(RpcNotSupported);
return finish(rpcError(RpcNotSupported));
context.loadType = resource::kFeeHeavyBurdenRpc;
@@ -73,8 +83,8 @@ doRipplePathFind(rpc::JsonContext& context)
rpc::tuning::kMaxValidatedLedgerAge)
{
if (context.apiVersion == 1)
return rpcError(RpcNoNetwork);
return rpcError(RpcNotSynced);
return finish(rpcError(RpcNoNetwork));
return finish(rpcError(RpcNotSynced));
}
PathRequest::pointer request;
@@ -175,17 +185,17 @@ doRipplePathFind(rpc::JsonContext& context)
jvResult = request->doStatus(context.params);
}
return jvResult;
return finish(std::move(jvResult));
}
// The caller specified a ledger
jvResult = rpc::lookupLedger(lpLedger, context);
if (!lpLedger)
return jvResult;
return finish(std::move(jvResult));
rpc::LegacyPathFind const lpf(isUnlimited(context.role), context.app);
if (!lpf.isOk())
return rpcError(RpcTooBusy);
return finish(rpcError(RpcTooBusy));
auto result = context.app.getPathRequestManager().doLegacyPathRequest(
context.consumer, lpLedger, context.params);
@@ -193,7 +203,7 @@ doRipplePathFind(rpc::JsonContext& context)
for (auto& fieldName : jvResult.getMemberNames())
result[fieldName] = std::move(jvResult[fieldName]);
return result;
return finish(std::move(result));
}
} // namespace xrpl