Files
rippled/src/libxrpl/telemetry/MetricsRegistry.cpp
Pratik Mankawde 327b5bb229 fix(telemetry): keep the telemetry-off build clean
A -DXRPL_ENABLE_TELEMETRY=0 build expands every XRPL_METRIC_* macro to
nothing, so anything whose only use sits inside one looks unused.

- MetricsRegistry::hasPipeline touches no member in that build, so
  clang-tidy asks for it to be static. Making it static would give the two
  builds different signatures, so suppress it the way Recording.h already
  does for the same reason.
- PathRequest.cpp's only use of MetricMacros.h is such a macro, so mark the
  include as kept.
- AppMetricGauges.cpp names MetricsRegistry and beast::Journal in a
  constructor signature that is compiled either way, so their includes move
  outside the telemetry guard.
2026-09-17 13:44:53 +01:00

609 lines
24 KiB
C++

/**
* MetricsRegistry implementation — the OpenTelemetry metrics pipeline.
*
* This file contains:
* - Construction / destruction logic for the OTel MeterProvider pipeline.
* - Synchronous instrument creation (counters, histograms) for RPC, job
* queue and the external dashboard parity counters.
* - The record / increment methods app code pushes values through.
* - No-op stubs when XRPL_ENABLE_TELEMETRY is not defined.
*/
// On Windows, OTel's spin_lock_mutex.h (transitively included from
// MetricsRegistry.h) defines _WINSOCKAPI_ and includes <windows.h>.
// This poisons the include state for boost/asio/detail/socket_types.hpp,
// which requires winsock2.h to be included first. Pre-including the
// boost/asio socket types header gets winsock2.h in before the OTel
// headers can interfere.
#ifdef _MSC_VER
#include <boost/asio/detail/socket_types.hpp>
#endif
#include <xrpl/telemetry/MetricsRegistry.h>
// Unguarded because the constructor's `beast::Journal journal` parameter is
// declared in both configurations; only the member it initialises is guarded.
#include <xrpl/beast/utility/Journal.h>
#ifdef XRPL_ENABLE_TELEMETRY
#include <xrpl/basics/Log.h>
#include <xrpl/telemetry/GetObjectMetricNames.h>
#include <xrpl/telemetry/HistogramBuckets.h>
#include <xrpl/telemetry/RpcMetricNames.h>
#include <xrpl/telemetry/SpanNames.h>
// For networkTypeFromId(), the one xrpl.network.type mapping both export
// paths use, plus noopMeter() and the shared meter name and version.
#include <xrpl/telemetry/Telemetry.h>
#include <opentelemetry/context/context.h>
#include <opentelemetry/exporters/otlp/otlp_http_metric_exporter_factory.h>
#include <opentelemetry/exporters/otlp/otlp_http_metric_exporter_options.h>
#include <opentelemetry/nostd/shared_ptr.h>
#include <opentelemetry/sdk/metrics/aggregation/aggregation_config.h>
#include <opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_factory.h>
#include <opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_options.h>
#include <opentelemetry/sdk/metrics/instruments.h>
#include <opentelemetry/sdk/metrics/meter_provider_factory.h>
#include <opentelemetry/sdk/metrics/view/instrument_selector_factory.h>
#include <opentelemetry/sdk/metrics/view/meter_selector_factory.h>
#include <opentelemetry/sdk/metrics/view/view_factory.h>
#include <opentelemetry/sdk/metrics/view/view_registry.h>
#include <opentelemetry/sdk/resource/resource.h>
#include <opentelemetry/semconv/incubating/service_attributes.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <exception>
#include <memory>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace metric_sdk = opentelemetry::sdk::metrics;
namespace otlp_http = opentelemetry::exporter::otlp;
// Not `resource`: that would collide with xrpl::resource (the resource-accounting
// namespace), which encloses every use site below. Inner-scope lookup would find
// that namespace instead of this file-scope alias.
namespace otel_resource = opentelemetry::sdk::resource;
namespace {
// Microsecond-valued duration histogram instrument names. Each is
// referenced twice — once to register the explicit-bucket view and once
// to create the instrument — so they are named constants to keep the two
// sites in sync (a mismatch would silently drop the bucket override).
constexpr char kJobQueuedDurationUs[] = "job_queued_us";
constexpr char kJobRunningDurationUs[] = "job_running_us";
constexpr char kRpcMethodDurationUs[] = "rpc_method_us";
// Attribute (label) keys for the job instruments. Each is referenced from
// several record sites, and a counter and its histogram must carry exactly
// the same key spelling or the two series cannot be joined in a query.
constexpr char kJobTypeLabel[] = "job_type";
constexpr char kHandlerLabel[] = "handler";
/**
* Register an explicit-bucket histogram view.
*
* The SDK's default boundaries top out at 10,000, so any instrument whose
* values exceed that saturates and every quantile reads as the ceiling. The
* floor matters just as much and is easier to miss: a ladder whose first edge
* sits above the mass of the distribution makes every low quantile an
* interpolation inside bucket 0 -- a number derived from the bucket edge
* rather than from any sample. Both ends are chosen from measured
* distributions in HistogramBuckets.h.
*
* @param views The registry to add the view to.
* @param name Instrument name to match (e.g. "job_running_us").
* @param boundaries Bucket upper bounds, ascending.
*/
void
addHistogramView(
metric_sdk::ViewRegistry& views,
std::string const& name,
std::vector<double> boundaries)
{
auto config = std::make_shared<metric_sdk::HistogramAggregationConfig>();
config->boundaries_ = std::move(boundaries);
auto selector = metric_sdk::InstrumentSelectorFactory::Create(
metric_sdk::InstrumentType::kHistogram, name, "");
auto meterSelector = metric_sdk::MeterSelectorFactory::Create(
std::string(xrpl::telemetry::kMeterName), std::string(xrpl::telemetry::kMeterVersion), "");
auto view =
metric_sdk::ViewFactory::Create(name, "", metric_sdk::AggregationType::kHistogram, config);
views.AddView(std::move(selector), std::move(meterSelector), std::move(view));
}
/**
* Register the microsecond-ladder view for a duration instrument.
*
* Job wait/run times and RPC latencies routinely exceed the SDK default
* ceiling, so they all share `buckets::kMicrosecondBuckets`.
*
* @param views The registry to add the view to.
* @param name Instrument name to match.
*/
void
addMicrosecondHistogramView(metric_sdk::ViewRegistry& views, std::string const& name)
{
addHistogramView(
views,
name,
xrpl::telemetry::buckets::toVector(xrpl::telemetry::buckets::kMicrosecondBuckets));
}
} // namespace
#endif // XRPL_ENABLE_TELEMETRY
namespace xrpl::telemetry {
MetricsRegistry::MetricsRegistry(
[[maybe_unused]] bool enabled,
[[maybe_unused]] beast::Journal journal,
[[maybe_unused]] Options const& options)
: enabled_(enabled)
#ifdef XRPL_ENABLE_TELEMETRY
, journal_(journal)
#endif
{
#ifdef XRPL_ENABLE_TELEMETRY
if (!enabled_)
return;
// useTls is logged because a collector that requires TLS rejects a
// plaintext exporter with no local error. The paths are left out.
JLOG(journal_.info()) << "MetricsRegistry: starting, endpoint=" << options.endpoint
<< ", serviceName=" << options.serviceName
<< ", serviceVersion=" << options.serviceVersion
<< ", instanceId=" << options.serviceInstanceId
<< ", nodeId=" << options.nodeId << ", networkId=" << options.networkId
<< ", useTls=" << options.useTls;
// A broken pipeline must not stop the node. The SDK is third-party code,
// so the catch-all is deliberate, as in ~ApplicationImp.
try
{
initExporterAndProvider(options);
// Rule for anything added below: the constructor may create only
// instruments whose recording is PUSHED from app code -- counters and
// histograms. An instrument registered here is live immediately, and
// the reader thread may invoke a registered callback before the rest
// of the server is built, so any observable whose callback reads live
// server state belongs in the layer that owns those callbacks, not
// here. That includes observable COUNTERS, not just gauges:
// jq_trans_overflow_total was created here and its callback read
// getOverlay(), which asserts overlay_ is non-null.
initSyncInstruments();
}
catch (std::exception const& e)
{
disablePipeline(e.what());
return;
}
catch (...)
{
disablePipeline("unknown exception");
return;
}
JLOG(journal_.info()) << "MetricsRegistry: provider and instruments ready";
#endif // XRPL_ENABLE_TELEMETRY
}
#ifdef XRPL_ENABLE_TELEMETRY
void
MetricsRegistry::disablePipeline(std::string_view reason)
{
provider_.reset();
// A no-op meter keeps the invariant the XRPL_METRIC_* macros rely on: an
// enabled registry always has a meter, so every call site gets an inert
// instrument here with no check of its own.
meter_ = noopMeter(kMeterName);
JLOG(journal_.error()) << "MetricsRegistry: metrics pipeline failed to initialise, "
"continuing without native metrics: "
<< reason;
}
#endif // XRPL_ENABLE_TELEMETRY
MetricsRegistry::~MetricsRegistry()
{
stop();
}
#ifdef XRPL_ENABLE_TELEMETRY
void
MetricsRegistry::initExporterAndProvider(Options const& options)
{
// Configure OTLP/HTTP metric exporter. The TLS settings come from the one
// [telemetry] block that also drives the trace exporter in Telemetry.cpp,
// so both exporters reach the collector on the same terms.
otlp_http::OtlpHttpMetricExporterOptions exporterOpts;
exporterOpts.url = options.endpoint;
if (options.useTls)
{
exporterOpts.ssl_ca_cert_path = options.tlsCaCertPath;
exporterOpts.ssl_client_cert_path = options.tlsClientCertPath;
exporterOpts.ssl_client_key_path = options.tlsClientKeyPath;
}
auto exporter = otlp_http::OtlpHttpMetricExporterFactory::Create(exporterOpts);
// Configure periodic reader with 10-second export interval.
metric_sdk::PeriodicExportingMetricReaderOptions readerOpts;
readerOpts.export_interval_millis = std::chrono::milliseconds(10000);
readerOpts.export_timeout_millis = std::chrono::milliseconds(5000);
auto reader =
metric_sdk::PeriodicExportingMetricReaderFactory::Create(std::move(exporter), readerOpts);
// Stamp the same resource Telemetry::makeMetricsResource() builds for the
// trace pipeline. Both must agree: a node whose service.name or
// xrpl.network.type differs between the two pipelines splits its own
// series, and a dashboard filtering on either label shows only half.
//
// Use std::string, never a string literal: ResourceAttributes stores an
// OTel AttributeValue variant whose char-const* overload binds to bool,
// so a literal would be recorded as the boolean true.
otel_resource::ResourceAttributes attrs;
attrs[opentelemetry::semconv::service::kServiceName] = options.serviceName;
// int64_t, matching the trace resource. The same key with two types would
// give the two pipelines incompatible attribute values.
attrs[std::string(attr::networkId)] = static_cast<int64_t>(options.networkId);
// Derived here rather than passed in, so the id and the type label cannot
// disagree. Same helper the trace path uses.
attrs[std::string(attr::networkType)] = networkTypeFromId(options.networkId);
// The three below are left off when empty rather than stamped blank. An
// absent label reads as "not reported"; an empty one looks like a value.
if (!options.serviceVersion.empty())
attrs[opentelemetry::semconv::service::kServiceVersion] = options.serviceVersion;
if (!options.serviceInstanceId.empty())
attrs[opentelemetry::semconv::service::kServiceInstanceId] = options.serviceInstanceId;
// xrpl.node.id: the same per-node key the trace resource carries, so
// metrics and traces resolve to one node.
if (!options.nodeId.empty())
attrs[std::string(attr::nodeId)] = options.nodeId;
auto resourceAttrs = otel_resource::Resource::Create(attrs);
// Build a view registry with explicit microsecond buckets for the
// duration histograms. Without this they use the SDK default buckets
// (max 10,000 = 10 ms), saturating every quantile at 10 ms.
auto views = std::make_unique<metric_sdk::ViewRegistry>();
addMicrosecondHistogramView(*views, kJobQueuedDurationUs);
addMicrosecondHistogramView(*views, kJobRunningDurationUs);
addMicrosecondHistogramView(*views, kRpcMethodDurationUs);
// Recorded at its PeerImp.cpp call site, not created here, so the name
// comes from the shared constant both sites use.
addMicrosecondHistogramView(*views, kGetObjectLookupUs);
// The remaining two GetObject histograms are not durations, so the
// microsecond ladder above does not fit them. Both still need explicit
// boundaries: the SDK default stops at 10,000 and both ranges exceed it.
//
// Object counts run 1..kHardMaxReplyNodes (12288). The honest sync path
// asks for at most 8, so the low buckets are fine-grained and the upper
// ones follow the charge size bands (64, 1024) up to the hard cap.
addHistogramView(
*views, kGetObjectRequestObjects, buckets::toVector(buckets::kObjectCountBuckets));
// Charge values span 0 (free tier) to ~99k for a full-size all-miss
// request. Boundaries bracket the resource thresholds that decide a
// peer's fate -- kWarningThreshold (5000) and kDropThreshold (25000) --
// so a dashboard can show how close charges run to each.
addHistogramView(*views, kGetObjectCharge, buckets::toVector(buckets::kChargeBuckets));
// The two RPC request-count histograms are recorded at their ServerHandler
// and PathRequest call sites, so the names come from the shared constants
// all three sites use. Both are small counts, and the reason they need a
// view is the FLOOR rather than the ceiling: the SDK default edges start
// 0, 5, 10, 25, so a batch of one to five sub-requests -- the normal case --
// would land in a single bucket and every quantile over it would be an
// interpolation inside that bucket rather than a measurement.
//
// The object-count ladder is the fit: its 1, 2, 4, 8, 16 edges sit exactly
// where both distributions have their mass. Path counts are hard-bounded at
// kMaxPaths * kMaxAutoSrcCur = 352, well under its 12288 top. Batch sizes
// have no such cap; see the ceiling note in RpcMetricNames.h.
addHistogramView(*views, kRpcBatchSize, buckets::toVector(buckets::kObjectCountBuckets));
addHistogramView(
*views, kPathfindDiscoveredPaths, buckets::toVector(buckets::kObjectCountBuckets));
// Create MeterProvider with resource, then attach the metric reader.
provider_ = metric_sdk::MeterProviderFactory::Create(std::move(views), resourceAttrs);
provider_->AddMetricReader(std::move(reader));
// Get a meter for all xrpld instruments.
meter_ = provider_->GetMeter(std::string(kMeterName), std::string(kMeterVersion));
}
void
MetricsRegistry::initSyncInstruments()
{
// RPC per-method counters and histogram.
rpcStartedCounter_ =
meter_->CreateUInt64Counter("rpc_method_started_total", "Total RPC method calls started");
rpcFinishedCounter_ = meter_->CreateUInt64Counter(
"rpc_method_finished_total", "Total RPC method calls completed successfully");
rpcErroredCounter_ = meter_->CreateUInt64Counter(
"rpc_method_errored_total", "Total RPC method calls that errored");
rpcDurationHistogram_ = meter_->CreateDoubleHistogram(
kRpcMethodDurationUs, "RPC method execution time in microseconds");
// Job queue per-type counters and histograms.
jobQueuedCounter_ = meter_->CreateUInt64Counter("job_queued_total", "Total jobs enqueued");
jobStartedCounter_ = meter_->CreateUInt64Counter("job_started_total", "Total jobs started");
jobFinishedCounter_ = meter_->CreateUInt64Counter("job_finished_total", "Total jobs completed");
jobQueuedDurationHistogram_ = meter_->CreateDoubleHistogram(
kJobQueuedDurationUs, "Time jobs spent waiting in the queue (microseconds)");
jobRunningDurationHistogram_ =
meter_->CreateDoubleHistogram(kJobRunningDurationUs, "Job execution time in microseconds");
// --- External dashboard parity counters ---
ledgersClosedCounter_ =
meter_->CreateUInt64Counter("ledgers_closed_total", "Total ledgers closed by consensus");
validationsSentCounter_ = meter_->CreateUInt64Counter(
"validations_sent_total", "Total validations sent by this node");
validationsCheckedCounter_ = meter_->CreateUInt64Counter(
"validations_checked_total", "Total network validations received and checked");
stateChangesCounter_ =
meter_->CreateUInt64Counter("state_changes_total", "Total operating mode changes");
ledgerHistoryMismatchCounter_ = meter_->CreateUInt64Counter(
"ledger_history_mismatch_total", "Total built-vs-validated ledger mismatches by reason");
txqExpiredCounter_ = meter_->CreateUInt64Counter(
"txq_expired_total", "Total transactions expired out of the transaction queue");
txqDroppedCounter_ = meter_->CreateUInt64Counter(
"txq_dropped_total", "Total transactions refused admission to the queue by reason");
// Note: validation_agreements_total / validation_missed_total are monotonic
// ObservableCounters owned by the observable-gauge layer.
}
#endif // XRPL_ENABLE_TELEMETRY
void
MetricsRegistry::stop()
{
#ifdef XRPL_ENABLE_TELEMETRY
// Store Stopped with release ordering BEFORE the pipeline goes away.
// Every recording thread reads phase_ through recording() with acquire
// ordering, so any record that has not yet passed the gate will see
// Stopped and skip. Idempotent: destructor calls this after run() or
// ~ApplicationImp already did.
phase_.store(Phase::Stopped, std::memory_order_release);
if (!provider_)
return;
JLOG(journal_.info()) << "MetricsRegistry: stopping";
// meter_ is left alone on purpose. Job threads are still running here and
// may be inside a macro, so writing meter_ would race with their read.
// The recording() gate is what keeps them off the dying pipeline: only the
// macros read meter_, and none of them does so once phase_ is Stopped.
//
// SDK teardown order: Shutdown() stops the PeriodicExportingMetricReader
// thread (so no further gauge callbacks fire) and performs the final
// collect-and-export drain itself. The trailing ForceFlush() is a
// redundant safety net (a no-op once the reader is shut down), then
// reset() destroys the provider.
//
// provider_.reset() destroys MeterProvider -> MeterContext -> ViewRegistry
// -> each View -> its shared_ptr<AggregationConfig>. Live SDK
// SyncMetricStorage instances cached in call-site statics still hold a
// raw AggregationConfig pointer; a Record with a NEW attribute set after
// this point would fire the factory lambda and deref that dangling
// pointer, and a late meter()->CreateXxx would return null.
provider_->Shutdown();
provider_->ForceFlush();
provider_.reset();
JLOG(journal_.info()) << "MetricsRegistry: stopped";
#endif // XRPL_ENABLE_TELEMETRY
}
// This reads provider_ when telemetry is compiled in and touches no member
// when it is not, so clang-tidy asks for it to be static. Making it static
// would give the two builds different signatures.
// NOLINTBEGIN(readability-convert-member-functions-to-static)
bool
MetricsRegistry::hasPipeline() const noexcept
{
#ifdef XRPL_ENABLE_TELEMETRY
return provider_ != nullptr;
#else
return false;
#endif
}
// NOLINTEND(readability-convert-member-functions-to-static)
// -----------------------------------------------------------------
// Synchronous instrument recording — RPC metrics
// -----------------------------------------------------------------
void
MetricsRegistry::recordRpcStarted([[maybe_unused]] std::string_view method)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (!recording() || !rpcStartedCounter_)
return;
rpcStartedCounter_->Add(1, {{"method", std::string(method)}});
#endif
}
void
MetricsRegistry::recordRpcFinished(
[[maybe_unused]] std::string_view method,
[[maybe_unused]] std::int64_t durationUs)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (!recording() || !rpcFinishedCounter_)
return;
rpcFinishedCounter_->Add(1, {{"method", std::string(method)}});
if (rpcDurationHistogram_)
{
rpcDurationHistogram_->Record(
static_cast<double>(durationUs),
{{"method", std::string(method)}},
opentelemetry::context::Context{});
}
#endif
}
void
MetricsRegistry::recordRpcErrored(
[[maybe_unused]] std::string_view method,
[[maybe_unused]] std::int64_t durationUs)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (!recording() || !rpcErroredCounter_)
return;
rpcErroredCounter_->Add(1, {{"method", std::string(method)}});
if (rpcDurationHistogram_)
{
rpcDurationHistogram_->Record(
static_cast<double>(durationUs),
{{"method", std::string(method)}},
opentelemetry::context::Context{});
}
#endif
}
// -----------------------------------------------------------------
// Synchronous instrument recording — Job Queue metrics
// -----------------------------------------------------------------
void
MetricsRegistry::recordJobQueued(
[[maybe_unused]] std::string_view jobType,
[[maybe_unused]] std::string_view jobName)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (!recording() || !jobQueuedCounter_)
return;
jobQueuedCounter_->Add(
1,
{{kJobTypeLabel, std::string(jobType)},
{kHandlerLabel, std::string(sanitiseHandler(jobName))}});
#endif
}
void
MetricsRegistry::recordJobStarted(
[[maybe_unused]] std::string_view jobType,
[[maybe_unused]] std::string_view jobName,
[[maybe_unused]] std::int64_t queuedDurUs)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (!recording() || !jobStartedCounter_)
return;
// Build the attribute pair once: both the counter and the histogram
// must carry the identical label set or they cannot be joined.
std::string const handler(sanitiseHandler(jobName));
jobStartedCounter_->Add(1, {{kJobTypeLabel, std::string(jobType)}, {kHandlerLabel, handler}});
if (jobQueuedDurationHistogram_ && queuedDurUs >= 0)
{
// Guard against negative queued durations: the caller derives this
// from a steady-clock delta that can go slightly negative under clock
// skew or reordering. The OTel SDK rejects negative histogram values
// (logging a warning per call), so skip them rather than spam.
jobQueuedDurationHistogram_->Record(
static_cast<double>(queuedDurUs),
{{kJobTypeLabel, std::string(jobType)}, {kHandlerLabel, handler}},
opentelemetry::context::Context{});
}
#endif
}
void
MetricsRegistry::recordJobFinished(
[[maybe_unused]] std::string_view jobType,
[[maybe_unused]] std::string_view jobName,
[[maybe_unused]] std::int64_t runningDurUs)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (!recording() || !jobFinishedCounter_)
return;
std::string const handler(sanitiseHandler(jobName));
jobFinishedCounter_->Add(1, {{kJobTypeLabel, std::string(jobType)}, {kHandlerLabel, handler}});
if (jobRunningDurationHistogram_)
{
jobRunningDurationHistogram_->Record(
static_cast<double>(runningDurUs),
{{kJobTypeLabel, std::string(jobType)}, {kHandlerLabel, handler}},
opentelemetry::context::Context{});
}
#endif
}
// -----------------------------------------------------------------
// External dashboard parity counter increments
// -----------------------------------------------------------------
void
MetricsRegistry::incrementLedgersClosed()
{
#ifdef XRPL_ENABLE_TELEMETRY
if (recording() && ledgersClosedCounter_)
ledgersClosedCounter_->Add(1);
#endif
}
void
MetricsRegistry::incrementValidationsSent()
{
#ifdef XRPL_ENABLE_TELEMETRY
if (recording() && validationsSentCounter_)
validationsSentCounter_->Add(1);
#endif
}
void
MetricsRegistry::incrementValidationsChecked()
{
#ifdef XRPL_ENABLE_TELEMETRY
if (recording() && validationsCheckedCounter_)
validationsCheckedCounter_->Add(1);
#endif
}
void
MetricsRegistry::incrementStateChanges()
{
#ifdef XRPL_ENABLE_TELEMETRY
if (recording() && stateChangesCounter_)
stateChangesCounter_->Add(1);
#endif
}
void
MetricsRegistry::incrementLedgerHistoryMismatch(std::string_view reason)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (recording() && ledgerHistoryMismatchCounter_)
ledgerHistoryMismatchCounter_->Add(1, {{"reason", std::string(reason)}});
#endif
}
void
MetricsRegistry::incrementTxqExpired()
{
#ifdef XRPL_ENABLE_TELEMETRY
if (recording() && txqExpiredCounter_)
txqExpiredCounter_->Add(1);
#endif
}
void
MetricsRegistry::incrementTxqDropped(std::string_view reason)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (recording() && txqDroppedCounter_)
txqDroppedCounter_->Add(1, {{"reason", std::string(reason)}});
#endif
}
} // namespace xrpl::telemetry