Files
rippled/src/xrpld/perflog/detail/PerfLogImp.cpp
Pratik Mankawde 15596f5b8d feat(telemetry): pinpoint root cause of slow TMGetObjectByHash service
Slowness on the peer object-fetch path could be observed but not
attributed. Job duration metrics carry only `job_type`, and both
`RcvGetLedger` and `RcvGetObjByHash` report as `ledgerRequest`, so a
queue-wait spike could not be traced to a handler. Nothing measured
NodeStore cost, request size, or the differential charge.

Latency now decomposes into three additive parts, each separately
measurable:

    end-to-end = queue wait + NodeStore lookup + everything else

- `handler` label on job_queued_total/_started_total/_finished_total and
  job_queued_us/job_running_us. The value is sanitised: a name passes
  through only if non-empty and all ASCII letters, else "other". Two job
  names embed a ledger sequence, so a raw label would mint one series
  per ledger; the rule bounds the domain at 43 names plus "other".
- getobject_lookup_us, _request_objects, _lookups_total{result},
  _rejected_total{reason} and _charge, recorded at their call sites.
  All three histograms get explicit bucket views: the SDK default stops
  at 10,000, which every one of them exceeds.
- Per-job-type waiting/running/deferred gauges for the 35 non-special
  job types. `deferred` is the leading indicator, since addJob never
  rejects -- it defers, so backpressure otherwise shows up only as
  latency after the fact.

`JobQueue::collect()` snapshots the counters under the queue lock and
publishes gauges after releasing it. Writing them while holding the lock
would invert a lock order against the collector's own lock, which the
collector's flush thread already holds when it calls this hook.

Tests assert exact values, including that the charge is priced on the
requested count rather than the capped iteration count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:59:16 +01:00

566 lines
17 KiB
C++

#include <xrpld/perflog/detail/PerfLogImp.h>
#include <xrpld/app/main/Application.h>
#include <xrpld/telemetry/MetricMacros.h>
#include <xrpld/telemetry/MetricsRegistry.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/core/CurrentThreadName.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/config/BasicConfig.h>
#include <xrpl/config/Constants.h>
#include <xrpl/core/Job.h>
#include <xrpl/core/JobTypes.h>
#include <xrpl/core/PerfLog.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/json_writer.h>
#include <xrpl/nodestore/Database.h>
#include <xrpl/protocol/jss.h>
#include <boost/filesystem/operations.hpp>
#include <boost/system/detail/error_code.hpp>
#include <chrono>
#include <cstdint>
#include <functional>
#include <ios>
#include <memory>
#include <mutex>
#include <ostream>
#include <set>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace xrpl::perf {
PerfLogImp::Counters::Counters(std::set<char const*> const& labels, JobTypes const& jobTypes)
{
{
// populateRpc
rpc.reserve(labels.size());
for (std::string const label : labels)
{
auto const inserted = rpc.emplace(label, Rpc()).second;
if (!inserted)
{
// Ensure that no other function populates this entry.
// LCOV_EXCL_START
UNREACHABLE(
"xrpl::perf::PerfLogImp::Counters::Counters : failed to "
"insert label");
// LCOV_EXCL_STOP
}
}
}
{
// populateJq
jq.reserve(jobTypes.size());
for (auto const& [jobType, _] : jobTypes)
{
auto const inserted = jq.emplace(jobType, Jq()).second;
if (!inserted)
{
// Ensure that no other function populates this entry.
// LCOV_EXCL_START
UNREACHABLE(
"xrpl::perf::PerfLogImp::Counters::Counters : failed to "
"insert job type");
// LCOV_EXCL_STOP
}
}
}
}
json::Value
PerfLogImp::Counters::countersJson() const
{
json::Value rpcobj(json::ValueType::Object);
// totalRpc represents all rpc methods. All that started, finished, etc.
Rpc totalRpc;
for (auto const& proc : rpc)
{
Rpc value;
{
std::scoped_lock const lock(proc.second.mutex);
if ((proc.second.value.started == 0u) && (proc.second.value.finished == 0u) &&
(proc.second.value.errored == 0u))
{
continue;
}
value = proc.second.value;
}
json::Value p(json::ValueType::Object);
p[jss::started] = std::to_string(value.started);
totalRpc.started += value.started;
p[jss::finished] = std::to_string(value.finished);
totalRpc.finished += value.finished;
p[jss::errored] = std::to_string(value.errored);
totalRpc.errored += value.errored;
p[jss::duration_us] = std::to_string(value.duration.count());
totalRpc.duration += value.duration;
rpcobj[proc.first] = p;
}
if (totalRpc.started != 0u)
{
json::Value totalRpcJson(json::ValueType::Object);
totalRpcJson[jss::started] = std::to_string(totalRpc.started);
totalRpcJson[jss::finished] = std::to_string(totalRpc.finished);
totalRpcJson[jss::errored] = std::to_string(totalRpc.errored);
totalRpcJson[jss::duration_us] = std::to_string(totalRpc.duration.count());
rpcobj[jss::total] = totalRpcJson;
}
json::Value jobQueueObj(json::ValueType::Object);
// totalJq represents all jobs. All enqueued, started, finished, etc.
Jq totalJq;
for (auto const& proc : jq)
{
Jq value;
{
std::scoped_lock const lock(proc.second.mutex);
if ((proc.second.value.queued == 0u) && (proc.second.value.started == 0u) &&
(proc.second.value.finished == 0u))
{
continue;
}
value = proc.second.value;
}
json::Value j(json::ValueType::Object);
j[jss::queued] = std::to_string(value.queued);
totalJq.queued += value.queued;
j[jss::started] = std::to_string(value.started);
totalJq.started += value.started;
j[jss::finished] = std::to_string(value.finished);
totalJq.finished += value.finished;
j[jss::queued_duration_us] = std::to_string(value.queuedDuration.count());
totalJq.queuedDuration += value.queuedDuration;
j[jss::running_duration_us] = std::to_string(value.runningDuration.count());
totalJq.runningDuration += value.runningDuration;
jobQueueObj[JobTypes::name(proc.first)] = j;
}
if (totalJq.queued != 0u)
{
json::Value totalJqJson(json::ValueType::Object);
totalJqJson[jss::queued] = std::to_string(totalJq.queued);
totalJqJson[jss::started] = std::to_string(totalJq.started);
totalJqJson[jss::finished] = std::to_string(totalJq.finished);
totalJqJson[jss::queued_duration_us] = std::to_string(totalJq.queuedDuration.count());
totalJqJson[jss::running_duration_us] = std::to_string(totalJq.runningDuration.count());
jobQueueObj[jss::total] = totalJqJson;
}
json::Value counters(json::ValueType::Object);
// Be kind to reporting tools and let them expect rpc and jq objects
// even if empty.
counters[jss::rpc] = rpcobj;
counters[jss::job_queue] = jobQueueObj;
return counters;
}
json::Value
PerfLogImp::Counters::currentJson() const
{
auto const present = steady_clock::now();
json::Value jobsArray(json::ValueType::Array);
auto const jobs = [this] {
std::scoped_lock const lock(jobsMutex);
return this->jobs;
}();
for (auto const& j : jobs)
{
if (j.first == JtInvalid)
continue;
json::Value jobj(json::ValueType::Object);
jobj[jss::job] = JobTypes::name(j.first);
jobj[jss::duration_us] =
std::to_string(std::chrono::duration_cast<microseconds>(present - j.second).count());
jobsArray.append(jobj);
}
json::Value methodsArray(json::ValueType::Array);
std::vector<MethodStart> methods;
{
std::scoped_lock const lock(methodsMutex);
methods.reserve(this->methods.size());
for (auto const& m : this->methods)
methods.push_back(m.second);
}
for (auto m : methods)
{
json::Value methodobj(json::ValueType::Object);
methodobj[jss::method] = m.first;
methodobj[jss::duration_us] =
std::to_string(std::chrono::duration_cast<microseconds>(present - m.second).count());
methodsArray.append(methodobj);
}
json::Value current(json::ValueType::Object);
current[jss::jobs] = jobsArray;
current[jss::methods] = methodsArray;
return current;
}
//-----------------------------------------------------------------------------
void
PerfLogImp::openLog()
{
if (setup_.perfLog.empty())
return;
if (logFile_.is_open())
logFile_.close();
auto logDir = setup_.perfLog.parent_path();
if (!boost::filesystem::is_directory(logDir))
{
boost::system::error_code ec;
boost::filesystem::create_directories(logDir, ec);
if (ec)
{
JLOG(j_.fatal()) << "Unable to create performance log "
"directory "
<< logDir << ": " << ec.message();
signalStop_();
return;
}
}
logFile_.open(setup_.perfLog.c_str(), std::ios::out | std::ios::app);
if (!logFile_)
{
JLOG(j_.fatal()) << "Unable to open performance log " << setup_.perfLog << ".";
signalStop_();
}
}
void
PerfLogImp::run()
{
beast::setCurrentThreadName("perflog");
lastLog_ = system_clock::now();
while (true)
{
{
std::unique_lock<std::mutex> lock(mutex_);
if (cond_.wait_until(lock, lastLog_ + setup_.logInterval, [&] { return stop_; }))
{
return;
}
if (rotate_)
{
openLog();
rotate_ = false;
}
}
report();
}
}
void
PerfLogImp::report()
{
if (!logFile_)
{
// If logFile_ is not writable do no further work.
return;
}
auto const present = system_clock::now();
if (present < lastLog_ + setup_.logInterval)
return;
lastLog_ = present;
json::Value report(json::ValueType::Object);
report[jss::time] = to_string(std::chrono::floor<microseconds>(present));
{
std::scoped_lock const lock{counters_.jobsMutex};
report[jss::workers] = static_cast<unsigned int>(counters_.jobs.size());
}
report[jss::hostid] = hostname_;
report[jss::counters] = counters_.countersJson();
report[jss::nodestore] = json::ValueType::Object;
app_.getNodeStore().getCountsJson(report[jss::nodestore]);
report[jss::current_activities] = counters_.currentJson();
app_.getOPs().stateAccounting(report);
logFile_ << json::Compact{std::move(report)} << std::endl;
}
PerfLogImp::PerfLogImp(
Setup setup,
Application& app,
beast::Journal journal,
std::function<void()>&& signalStop)
: setup_(std::move(setup)), app_(app), j_(journal), signalStop_(std::move(signalStop))
{
openLog();
}
PerfLogImp::~PerfLogImp()
{
stop();
}
void
PerfLogImp::rpcStart(std::string const& method, std::uint64_t const requestId)
{
auto counter = counters_.rpc.find(method);
if (counter == counters_.rpc.end())
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::perf::PerfLogImp::rpcStart : valid method input");
return;
// LCOV_EXCL_STOP
}
{
std::scoped_lock const lock(counter->second.mutex);
++counter->second.value.started;
}
std::scoped_lock const lock(counters_.methodsMutex);
counters_.methods[requestId] = {counter->first.c_str(), steady_clock::now()};
// Task 9.4: Record RPC start in OTel metrics pipeline.
if (auto* mr = app_.getMetricsRegistry())
mr->recordRpcStarted(method);
// Proof-of-concept for tasks/metric-macro-plan.md Use Case 2: a value
// that must be able to decrease (UpDownCounter), added at its call
// site with no MetricsRegistry member/init-line/method. Paired with the
// matching -1 in rpcEnd(). Runs on the same path as recordRpcStarted
// above, i.e. only after a methods-map entry exists for this request.
XRPL_METRIC_UPDOWN_ADD(app_, "rpc_in_flight_requests", "RPC requests currently executing", 1);
}
void
PerfLogImp::rpcEnd(std::string const& method, std::uint64_t const requestId, bool finish)
{
auto counter = counters_.rpc.find(method);
if (counter == counters_.rpc.end())
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::perf::PerfLogImp::rpcEnd : valid method input");
return;
// LCOV_EXCL_STOP
}
steady_time_point startTime;
{
std::scoped_lock const lock(counters_.methodsMutex);
auto const e = counters_.methods.find(requestId);
if (e != counters_.methods.end())
{
startTime = e->second.second;
counters_.methods.erase(e);
}
else
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::perf::PerfLogImp::rpcEnd : valid requestId input");
// Without an entry, startTime stays default-initialized; returning
// avoids recording a bogus (now - epoch) duration to the counters
// and OTel histogram below. UNREACHABLE does not halt release builds.
return;
// LCOV_EXCL_STOP
}
}
auto const durationUs =
std::chrono::duration_cast<microseconds>(steady_clock::now() - startTime);
{
std::scoped_lock const lock(counter->second.mutex);
if (finish)
{
++counter->second.value.finished;
}
else
{
++counter->second.value.errored;
}
counter->second.value.duration += durationUs;
}
// Task 9.4: Record RPC completion in OTel metrics pipeline.
// Mirrors the rpcStart() instrumentation so the finished/errored
// counters and duration histogram advance with every call.
if (auto* mr = app_.getMetricsRegistry())
{
if (finish)
{
mr->recordRpcFinished(method, durationUs.count());
}
else
{
mr->recordRpcErrored(method, durationUs.count());
}
}
// Matching -1 for the +1 recorded in rpcStart(). Placed after the early
// returns above so it runs only when this request's methods-map entry was
// found (i.e. a +1 was recorded for it), keeping the in-flight count balanced.
XRPL_METRIC_UPDOWN_ADD(app_, "rpc_in_flight_requests", "RPC requests currently executing", -1);
}
void
PerfLogImp::jobQueue(JobType const type, std::string const& name)
{
auto counter = counters_.jq.find(type);
if (counter == counters_.jq.end())
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::perf::PerfLogImp::jobQueue : valid job type input");
return;
// LCOV_EXCL_STOP
}
std::scoped_lock const lock(counter->second.mutex);
++counter->second.value.queued;
// Task 9.5: Record job enqueue in OTel metrics pipeline.
if (auto* mr = app_.getMetricsRegistry())
mr->recordJobQueued(JobTypes::name(type), name);
}
void
PerfLogImp::jobStart(
JobType const type,
std::string const& name,
microseconds dur,
steady_time_point startTime,
int instance)
{
auto counter = counters_.jq.find(type);
if (counter == counters_.jq.end())
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::perf::PerfLogImp::jobStart : valid job type input");
return;
// LCOV_EXCL_STOP
}
{
std::scoped_lock const lock(counter->second.mutex);
++counter->second.value.started;
counter->second.value.queuedDuration += dur;
}
std::scoped_lock const lock(counters_.jobsMutex);
if (instance >= 0 && instance < counters_.jobs.size())
counters_.jobs[instance] = {type, startTime};
// Task 9.5: Record job start in OTel metrics pipeline.
if (auto* mr = app_.getMetricsRegistry())
mr->recordJobStarted(JobTypes::name(type), name, dur.count());
}
void
PerfLogImp::jobFinish(JobType const type, std::string const& name, microseconds dur, int instance)
{
auto counter = counters_.jq.find(type);
if (counter == counters_.jq.end())
{
// LCOV_EXCL_START
UNREACHABLE("xrpl::perf::PerfLogImp::jobFinish : valid job type input");
return;
// LCOV_EXCL_STOP
}
{
std::scoped_lock const lock(counter->second.mutex);
++counter->second.value.finished;
counter->second.value.runningDuration += dur;
}
std::scoped_lock const lock(counters_.jobsMutex);
if (instance >= 0 && instance < counters_.jobs.size())
counters_.jobs[instance] = {JtInvalid, steady_time_point()};
// Task 9.5: Record job finish in OTel metrics pipeline.
if (auto* mr = app_.getMetricsRegistry())
mr->recordJobFinished(JobTypes::name(type), name, dur.count());
}
void
PerfLogImp::resizeJobs(int const resize)
{
std::scoped_lock const lock(counters_.jobsMutex);
if (resize > counters_.jobs.size())
counters_.jobs.resize(resize, {JtInvalid, steady_time_point()});
}
void
PerfLogImp::rotate()
{
if (setup_.perfLog.empty())
return;
std::scoped_lock const lock(mutex_);
rotate_ = true;
cond_.notify_one();
}
void
PerfLogImp::start()
{
if (!setup_.perfLog.empty())
thread_ = std::thread(&PerfLogImp::run, this);
}
void
PerfLogImp::stop()
{
if (thread_.joinable())
{
{
std::scoped_lock const lock(mutex_);
stop_ = true;
cond_.notify_one();
}
thread_.join();
}
}
//-----------------------------------------------------------------------------
PerfLog::Setup
setupPerfLog(Section const& section, boost::filesystem::path const& configDir)
{
PerfLog::Setup setup;
std::string perfLog;
set(perfLog, "perf_log", section);
if (!perfLog.empty())
{
setup.perfLog = boost::filesystem::path(perfLog);
if (setup.perfLog.is_relative())
{
setup.perfLog = boost::filesystem::absolute(setup.perfLog, configDir);
}
}
std::uint64_t logInterval = 0;
if (getIfExists(section, Keys::kLogInterval, logInterval))
setup.logInterval = std::chrono::seconds(logInterval);
return setup;
}
std::unique_ptr<PerfLog>
makePerfLog(
PerfLog::Setup const& setup,
Application& app,
beast::Journal journal,
std::function<void()>&& signalStop)
{
return std::make_unique<PerfLogImp>(setup, app, journal, std::move(signalStop));
}
} // namespace xrpl::perf