merge: bring the node-identity change forward from phase-8

Three conflicts, all from both branches editing the same passage:

- Main.cpp: kept phase-9's wording. The metrics registry only exists here, so
  "unwinding destroys little: the metrics registry, whose destructor joins its
  export thread" is the true statement on this branch.
- TESTING.md: kept both paragraphs. They document different things (the
  private [network_id], and the log path plus log_level).
- 05-configuration-reference.md: composed both. The identity is now resolved
  before construction and never empty, so every producer stamps the node key
  from the start; the only divergence left is a wallet that already holds a
  different key, which corrects the tracer alone. Rewrote the earlier
  "three producers" blockquote too: its "no fallback", "first boot ... left
  off" and "Known issue" claims are what this change removes.

One silent break the merge could not flag: makeMetricsRegistryOptions() took
the std::optional<std::string> node key that used to be a constructor
parameter, and that parameter is now the resolved keypair. It takes the base58
string directly, and the constructor derives it from nodeIdentity_, which is
declared before both telemetry_ and metricsRegistry_.
This commit is contained in:
Pratik Mankawde
2026-09-14 20:39:18 +01:00
16 changed files with 751 additions and 238 deletions

View File

@@ -202,6 +202,7 @@ tests.libxrpl > xrpl.nodestore
tests.libxrpl > xrpl.peerfinder
tests.libxrpl > xrpl.protocol
tests.libxrpl > xrpl.protocol_autogen
tests.libxrpl > xrpl.rdb
tests.libxrpl > xrpl.resource
tests.libxrpl > xrpl.server
tests.libxrpl > xrpl.shamap

View File

@@ -13,55 +13,43 @@
The authoritative `[telemetry]` example lives in `cfg/xrpld-example.cfg`. Telemetry is disabled by default (`enabled=0`); enabling it turns on distributed tracing for transaction flow, consensus, and RPC calls, with traces exported to an OpenTelemetry Collector over OTLP. Head sampling is intentionally fixed at 1.0 (sample everything) and is not configurable — per-node head-sampling would produce broken/partial distributed traces, so volume reduction is delegated to the collector's tail sampling (see Section 7.4.2). Transaction trace IDs are always deterministic (`trace_id = txHash[0:16]`); there is no strategy switch for the transaction path. The full option reference follows.
> **`service_instance_id` is effectively required for `beast::insight`
> metrics — and only for those.** Three producers resolve the instance id
> independently, and exactly one of them lacks a node-key fallback:
> **`service_instance_id` defaults to the node public key for every producer.**
> `Main.cpp` resolves the identity before `ApplicationImp` is constructed and it
> is never empty, so all three producers below stamp the node key when the config
> key is unset. They still build their resources at three different times, which
> is what decides how each behaves if the identity ever changes mid-run:
>
> | Producer | Resource built by | Unset `service_instance_id` yields |
> | ------------------------------------------- | -------------------------------------------- | ---------------------------------------------- |
> | Traces (and therefore all `span_*` metrics) | `Telemetry::start()` | Base58 node public key |
> | Native `XRPL_METRIC_*` (`MetricsRegistry`) | `MetricsRegistry::initExporterAndProvider()` | Base58 node public key |
> | `beast::insight` (`[insight] server=otel`) | `TelemetryImpl` **constructor** | **`service.instance.id` absent** — no fallback |
> | Producer | Resource built by | Unset `service_instance_id` yields |
> | ------------------------------------------- | -------------------------------------------- | ---------------------------------- |
> | Traces (and therefore all `span_*` metrics) | `Telemetry::start()`, during `setup()` | Base58 node public key |
> | Native `XRPL_METRIC_*` (`MetricsRegistry`) | `MetricsRegistry::initExporterAndProvider()` | Base58 node public key |
> | `beast::insight` (`[insight] server=otel`) | `TelemetryImpl` **constructor** | Base58 node public key |
>
> - **Traces**: the tracer resource is built in `Telemetry::start()`
> (`Telemetry.cpp:380-387`), which runs after `ApplicationImp::setup()` has
> called `setServiceInstanceId()` (in `ApplicationImp::setup()`) with the Base58
> node public key. An unset key therefore still yields the node key. The
> `spanmetrics` connector derives `span_calls_total` /
> `span_duration_milliseconds_*` from those spans, so span metrics inherit
> the correct id too.
> - **Native `XRPL_METRIC_*` metrics** build their **own** MeterProvider
> resource in `MetricsRegistry::initExporterAndProvider()`, called from the
> registry's constructor. `makeMetricsRegistryOptions()` in `Application.cpp`
> supplies the id: the config key when set, else the node public key that
> `Main.cpp` resolves before `ApplicationImp` is constructed (the same source
> `Telemetry`'s own metrics resource uses). On a first boot with no node key
> yet, both `service_instance_id` and `xrpl.node.id` are left off until the
> next restart.
> - **`beast::insight` metrics** are the exception. They use the **global**
> MeterProvider, whose resource is built in the `TelemetryImpl`
> **constructor** (`Telemetry.cpp:321-338`, `initMetrics()` at `:447`),
> because insight instruments are created eagerly in subsystem constructors
> and would otherwise bind to the noop provider forever. The constructor
> receives the key `Main.cpp` resolved, which is empty when no key exists
> yet, and the code comment in `TelemetryImpl::initMetrics()` states plainly
> that the later setter "cannot change this immutable resource". Worse,
> `initMetrics()` sets the attribute **unconditionally**, so on such a run
> the resource carries `service.instance.id=""` rather than omitting it —
> whereas `MetricsRegistry` guards the same write with
> `if (!options.serviceInstanceId.empty())` in `MetricsRegistry::initExporterAndProvider()`.
> - **Traces**: the tracer resource is built in `Telemetry::start()`, which runs
> inside `setup()` after `getNodeIdentity()` has persisted the identity, so it
> is the one resource that can still be corrected by
> `setServiceInstanceId()`. The `spanmetrics` connector derives
> `span_calls_total` / `span_duration_milliseconds_*` from those spans, so span
> metrics inherit the same id.
> - **Native `XRPL_METRIC_*` metrics** build their **own** MeterProvider resource
> in `MetricsRegistry::initExporterAndProvider()`, called from the registry's
> constructor. `makeMetricsRegistryOptions()` in `Application.cpp` supplies the
> id: the config key when set, else the resolved node key.
> - **`beast::insight` metrics** use the **global** MeterProvider, whose resource
> is built in the `TelemetryImpl` **constructor**, because insight instruments
> are created eagerly in subsystem constructors and would otherwise bind to the
> noop provider forever. It receives the same resolved key. Note it writes the
> attribute **unconditionally**, where `MetricsRegistry` guards the write with
> `if (!options.serviceInstanceId.empty())`, so a deliberately blank
> `service_instance_id` yields `service.instance.id=""` there and an absent
> attribute here.
>
> Result: with `service_instance_id` unset, `beast::insight` metrics — and only
> those — export with an empty `service.instance.id`. Every shipped Grafana
> dashboard filters on `service_instance_id=~"$node"`, so **insight-backed
> panels** lose their per-node dimension; span-metric and `XRPL_METRIC_*`
> panels are unaffected. Set the key explicitly on any node whose insight
> metrics are dashboarded.
>
> **Known issue.** The asymmetry is a defect, not a design: `MetricsRegistry`
> already demonstrates the node-key fallback that the global provider needs.
> A fix would have to resolve the node identity before `TelemetryImpl` is
> constructed, or make the insight metrics use a late-built provider.
> The one case where the three can disagree: a node whose wallet already holds an
> identity different from the resolved one (another process wrote it between
> construction and `setup()`). `setServiceInstanceId()` then corrects the tracer,
> and both metric resources, already frozen, need a restart to follow. Every
> shipped Grafana dashboard filters on `service_instance_id=~"$node"`, so that run
> shows its metrics under the resolved key and its traces under the stored one.
### 5.1.2 Configuration Options Summary
@@ -133,17 +121,23 @@ Setting `traces_endpoint` therefore moves traces only; both metric pipelines fol
### 5.3.1 ApplicationImp Changes
> **Identity at construction**: `Main.cpp` resolves the node public key with
> `resolveNodePublicKey()` before `ApplicationImp` is built and passes it to
> the constructor, so both metric resources — the **global** MeterProvider the
> `beast::insight` metrics use, and `MetricsRegistry`'s separate one — carry it
> from the start. `getNodeIdentity()` in `setup()` stays authoritative; when it
> mints a key that did not exist at construction (a first boot), it patches the
> tracer via `setServiceInstanceId()`. **That patch reaches traces only**: both
> metric resources are frozen once their providers are built, so on that one
> run the metrics report without a node id until the next restart.
> **Identity before construction**: telemetry stamps the node public key into
> resources that are immutable once built, and it builds them during
> `ApplicationImp`'s member initializer list. So `Main.cpp` calls
> `resolveNodeIdentity()` first, from the config and command line alone, and
> passes the keypair to `makeApplication()`. It never comes back empty: a
> configured `[node_seed]` decides it, else the wallet database supplies it if
> one already exists, else it is minted. Every producer therefore carries the
> node key from the start — the tracer, the **global** MeterProvider the
> `beast::insight` metrics use, and `MetricsRegistry`'s separate one.
> `ApplicationImp::setup()` then calls `getNodeIdentity()`, which stores that
> keypair when the wallet holds none and otherwise adopts what the wallet holds.
> Only in that second case can the two differ, and then
> `setServiceInstanceId()` corrects the tracer alone: both metric resources are
> frozen once their providers are built, so those metrics need a restart to
> report the stored key.
`ApplicationImp` (in `src/xrpld/app/main/Application.cpp`) owns a `std::unique_ptr<telemetry::Telemetry> telemetry_` and, declared right after it, a `std::unique_ptr<telemetry::MetricsRegistry> metricsRegistry_`. Both are built in the member initializer list, before every subsystem, from the node key `Main.cpp` resolved (empty on a first boot). `setup()` patches the tracer via `setServiceInstanceId()` if `getNodeIdentity()` minted a new key, starts tracing with `startTelemetry()` before the first consensus round, and arms the registry's observable gauges with `startTelemetryGauges()` once `overlay_` exists. `run()` stops both observers before any service, then stops telemetry last; `~ApplicationImp` repeats those stops for the paths that never reach `run()`. `getTelemetry()` and `getMetricsRegistry()` return the owned instances.
`ApplicationImp` (in `src/xrpld/app/main/Application.cpp`) owns a `std::pair<PublicKey, SecretKey> nodeIdentity_`, declared before `std::unique_ptr<telemetry::Telemetry> telemetry_` and `std::unique_ptr<telemetry::MetricsRegistry> metricsRegistry_` so both resources can be built from it. All three are built in the member initializer list, before every subsystem. `setup()` persists the identity, starts tracing with `startTelemetry()` before the first consensus round, and arms the registry's observable gauges with `startTelemetryGauges()` once `overlay_` exists. `run()` stops both observers before any service, then stops telemetry last; `~ApplicationImp` repeats those stops for the paths that never reach `run()`. `getTelemetry()` and `getMetricsRegistry()` return the owned instances.
### 5.3.2 ServiceRegistry Interface Addition

View File

@@ -299,14 +299,14 @@ protocol = peer
[node_db]
type=NuDB
path=/tmp/xrpld-integration/node{N}/nudb
path=/tmp/xrpld-integration/Node-{N}/nudb
online_delete=256
[database_path]
/tmp/xrpld-integration/node{N}/db
/tmp/xrpld-integration/Node-{N}/db
[debug_logfile]
/tmp/xrpld-integration/node{N}/debug.log
/tmp/xrpld-integration/Node-{N}/debug.log
[validation_seed]
{seed from step 2}
@@ -337,11 +337,13 @@ trace_peer=1
trace_ledger=1
[insight]
# server=otel is the only load-bearing key here -- it selects OTelCollector.
# The export endpoint comes from [telemetry] metrics_endpoint, and [insight]'s
# own service_instance_id/service_name keys are ignored.
server=otel
endpoint=http://localhost:4318/v1/metrics
[rpc_startup]
{ "command": "log_level", "severity": "warning" }
{ "command": "log_level", "severity": "info" }
[ssl_verify]
0
@@ -355,6 +357,16 @@ mainnet data. Only 0, 1 and 2 have names, so a private id is stamped
`xrpl.network.type=unknown`. That is the value to select in the dashboards'
Network Type filter when looking at this cluster.
The per-node directory name must equal `[telemetry] service_instance_id`: the
collector reads the node name off the log file's path and stamps it as the Loki
label `service_instance_id`, so a mismatch leaves the logs labelled with a node
name that no trace or metric shares.
`log_level` is `info`, not `warning`. A log line carries trace context only when
it is emitted inside an active span, and the pair that reliably carries it — the
`CNF Val` / `CNF buildLCL` branches inside the consensus accept span, one of
which fires for every accepted ledger — logs at `info`.
#### Step 4: Create validators.txt
```ini
@@ -689,9 +701,15 @@ Expected: log lines with `trace_id=<32hex> span_id=<16hex>` between the
severity code and the message. Example:
```
2024-Jan-15 10:30:45.123456789 UTC RPCHandler:NFO trace_id=abc123def456789012345678abcdef01 span_id=0123456789abcdef Calling server_info
2024-Jan-15 10:30:45.123456789 UTC RPCHandler:DBG trace_id=abc123def456789012345678abcdef01 span_id=0123456789abcdef RPC call server_info completed in 0.000123seconds
```
That example is a Test 1 line. `xrpld-telemetry.cfg` logs at `debug`, so the
in-span RPC statement above appears. Test 2's nodes log at `info`, which
suppresses it — there, look for the `CNF Val` / `CNF buildLCL` lines from the
consensus accept span instead. Either carries trace context; only the message
differs.
Lines emitted outside of an active span (background tasks, startup) will
NOT have trace context — this is expected.
@@ -733,9 +751,9 @@ Use `query_range`, not `query`. Loki rejects a bare log selector on the
instant `/query` endpoint with HTTP 400 and a `text/plain` body
("log queries are not supported as an instant query type"), so `jq` fails to
parse it and the step never prints a number — even when ingestion is working.
Only metric queries such as `sum(count_over_time(...))` are allowed there,
which is why the validation scripts can use the instant endpoint.
Timestamps are unix nanoseconds, matching `workload/validate_telemetry.py`.
Only metric queries such as `sum(count_over_time(...))` are allowed there, so a
check that needs a count rather than the lines themselves can use the instant
endpoint. `query_range` timestamps are unix nanoseconds.
Counting `.data.result | length` would count streams, not log lines.
> **Use `service_name`, not `job`.** The local stack's `resource/logs` processor
@@ -763,7 +781,7 @@ Counting `.data.result | length` would count streams, not log lines.
1. Open Grafana at http://localhost:3000
2. Navigate to **Explore** -> select **Tempo** datasource
3. Search for a trace (e.g., operation `rpc.command.server_info`)
4. Click **"Logs for this trace"** in the trace detail view
4. Expand a span and click **"Logs for this span"** in its **Links** row
5. Verify that Loki log lines appear, filtered by the trace's `trace_id`
### Step 5: Verify Grafana Loki-to-Tempo correlation
@@ -775,15 +793,15 @@ Counting `.data.result | length` would count streams, not log lines.
### Expected results
| Check | Expected |
| ------------------------------ | ---------------------------------------- |
| `trace_id=` in debug.log | Present in log lines within active spans |
| `span_id=` in debug.log | Present alongside trace_id |
| Logs without active span | No trace_id/span_id fields |
| trace_id in Tempo | Matches a valid trace |
| Loki log ingestion | Logs visible via LogQL |
| Tempo -> Loki "Logs for trace" | Shows correlated log lines |
| Loki -> Tempo TraceID link | Navigates to correct trace |
| Check | Expected |
| --------------------------- | ---------------------------------------- |
| `trace_id=` in debug.log | Present in log lines within active spans |
| `span_id=` in debug.log | Present alongside trace_id |
| Logs without active span | No trace_id/span_id fields |
| trace_id in Tempo | Matches a valid trace |
| Loki log ingestion | Logs visible via LogQL |
| Tempo -> Loki span log link | Shows correlated log lines |
| Loki -> Tempo TraceID link | Navigates to correct trace |
---

View File

@@ -102,6 +102,22 @@ clearNodeIdentity(soci::session& session);
std::optional<std::pair<PublicKey, SecretKey>>
readNodeIdentity(soci::session& session);
/**
* Persist a keypair as this node's identity.
*
* Write-only counterpart of readNodeIdentity(). The caller must have found the
* table empty: this inserts a row without clearing, so storing twice leaves two
* and readNodeIdentity() then returns whichever the query yields first.
*
* Exists because xrpld resolves its identity before the Application, and so
* before any database, is built; setup() persists that keypair here.
*
* @param session Session with the database.
* @param keys The keypair to store.
*/
void
storeNodeIdentity(soci::session& session, std::pair<PublicKey, SecretKey> const& keys);
/**
* Returns a stable public and private key for this node.
*

View File

@@ -134,6 +134,25 @@ inline constexpr std::string_view kMeterName{"xrpld"};
* OTel instrumentation scope version reported for the meter.
*/
inline constexpr std::string_view kMeterVersion{"1.0.0"};
/**
* A meter whose instruments record nothing.
*
* For every path that must hand out a usable meter without a pipeline behind
* it: telemetry disabled, or an exporter that failed to build. Callers then
* need no null check, because an instrument always comes back.
*
* Two details are easy to get wrong alone, which is why this is shared: the
* provider must be reached through a base `MeterProvider` pointer, because
* `NoopMeterProvider`'s override hides the base class's defaulted overload;
* and the version must be @ref kMeterVersion, or the meter identity differs
* from the one the histogram views select on.
*
* @param name Instrumentation scope name to report.
* @return An inert meter. Never empty.
*/
[[nodiscard]] opentelemetry::nostd::shared_ptr<opentelemetry::metrics::Meter>
noopMeter(std::string_view name = kMeterName);
#endif
/**

View File

@@ -512,17 +512,25 @@ public:
/**
* @brief Register a hook for periodic invocation.
* @param hook Pointer to the hook to register.
*
* Takes the owning shared_ptr so the list can store a weak reference.
* Called from makeHook() rather than the hook's constructor, because a
* weak_ptr cannot be formed until the shared_ptr owns the object.
*
* @param hook Owning pointer to the hook to register.
*/
void
addHook(OTelHookImpl* hook);
addHook(std::shared_ptr<OTelHookImpl> const& hook);
/**
* @brief Unregister a hook.
* @param hook Pointer to the hook to unregister.
* @brief Drop entries for hooks that have been destroyed.
*
* Called from ~OTelHookImpl. The dying hook's weak_ptr has already
* expired by then, so the entry is identified by expiry rather than by
* address.
*/
void
removeHook(OTelHookImpl* hook);
removeExpiredHooks();
/**
* @brief Invoke all registered hooks.
@@ -600,8 +608,16 @@ private:
/**
* Registered hooks called during observable callbacks.
*
* Weak, not owning, and not raw. callHooks() must invoke handlers with
* mutex_ released, because a handler may drop the last reference to a
* hook and ~OTelHookImpl re-acquires mutex_. A raw pointer copied out of
* this list could therefore be dangling by the time it is dereferenced.
* Locking a weak_ptr instead keeps the hook alive for exactly the
* duration of its own handler call, and an already-destroyed hook is
* skipped rather than followed.
*/
std::vector<OTelHookImpl*> hooks_;
std::vector<std::weak_ptr<OTelHookImpl>> hooks_;
/**
* Registered gauges read during observable callbacks.
@@ -637,12 +653,14 @@ private:
OTelHookImpl::OTelHookImpl(HandlerType handler, std::shared_ptr<OTelCollectorImp> impl)
: impl_(std::move(impl)), handler_(std::move(handler))
{
impl_->addHook(this);
// Registration happens in OTelCollectorImp::makeHook(), not here: the
// list holds weak references, and no weak_ptr to this object exists
// until the owning shared_ptr does.
}
OTelHookImpl::~OTelHookImpl()
{
impl_->removeHook(this);
impl_->removeExpiredHooks();
}
void
@@ -852,7 +870,9 @@ OTelCollectorImp::~OTelCollectorImp()
Hook
OTelCollectorImp::makeHook(HookImpl::HandlerType const& handler)
{
return Hook(std::make_shared<OTelHookImpl>(handler, shared_from_this()));
auto hook = std::make_shared<OTelHookImpl>(handler, shared_from_this());
addHook(hook);
return Hook(hook);
}
Counter
@@ -886,17 +906,17 @@ OTelCollectorImp::makeMeter(std::string const& name)
}
void
OTelCollectorImp::addHook(OTelHookImpl* hook)
OTelCollectorImp::addHook(std::shared_ptr<OTelHookImpl> const& hook)
{
std::scoped_lock const lock(mutex_);
hooks_.push_back(hook);
hooks_.emplace_back(hook);
}
void
OTelCollectorImp::removeHook(OTelHookImpl* hook)
OTelCollectorImp::removeExpiredHooks()
{
std::scoped_lock const lock(mutex_);
std::erase(hooks_, hook);
std::erase_if(hooks_, [](std::weak_ptr<OTelHookImpl> const& hook) { return hook.expired(); });
}
void
@@ -913,15 +933,22 @@ OTelCollectorImp::callHooks()
// Copy the hook list under the lock, then invoke handlers outside it.
// A handler may drop the last reference to an OTelHookImpl, whose
// destructor calls removeHook() and re-acquires mutex_; invoking
// handlers while holding the (non-recursive) lock would deadlock.
std::vector<OTelHookImpl*> hooks;
// destructor re-acquires mutex_; invoking handlers while holding the
// (non-recursive) lock would deadlock.
std::vector<std::weak_ptr<OTelHookImpl>> hooks;
{
std::scoped_lock const lock(mutex_);
hooks = hooks_;
}
for (auto* hook : hooks)
hook->callHandler();
// Locking each entry keeps that hook alive across its own handler call,
// so releasing mutex_ above cannot leave a dangling reference. A hook
// destroyed since the snapshot was taken locks to null and is skipped.
for (auto const& weakHook : hooks)
{
if (auto const hook = weakHook.lock())
hook->callHandler();
}
}
void

View File

@@ -171,6 +171,16 @@ readNodeIdentity(soci::session& session)
return std::nullopt;
}
void
storeNodeIdentity(soci::session& session, std::pair<PublicKey, SecretKey> const& keys)
{
session << std::format(
"INSERT INTO NodeIdentity (PublicKey,PrivateKey) "
"VALUES ('{}','{}');",
toBase58(TokenType::NodePublic, keys.first),
toBase58(TokenType::NodePrivate, keys.second));
}
std::pair<PublicKey, SecretKey>
getNodeIdentity(soci::session& session)
{
@@ -178,15 +188,9 @@ getNodeIdentity(soci::session& session)
return *stored;
// If a valid identity wasn't found, we randomly generate a new one:
auto [newpublicKey, newsecretKey] = randomKeyPair(KeyType::Secp256k1);
session << std::format(
"INSERT INTO NodeIdentity (PublicKey,PrivateKey) "
"VALUES ('{}','{}');",
toBase58(TokenType::NodePublic, newpublicKey),
toBase58(TokenType::NodePrivate, newsecretKey));
return {newpublicKey, newsecretKey};
auto const keys = randomKeyPair(KeyType::Secp256k1);
storeNodeIdentity(session, keys);
return keys;
}
std::unordered_set<PeerReservation, beast::Uhash<>, KeyEqual>

View File

@@ -261,11 +261,8 @@ public:
[[nodiscard]] opentelemetry::nostd::shared_ptr<metrics_api::Meter>
getMeter(std::string_view name) override
{
// Serve a meter from a process-wide noop provider, mirroring the
// noop tracer above. Instruments created from it are inert.
static auto noopProvider = opentelemetry::nostd::shared_ptr<metrics_api::MeterProvider>(
new metrics_api::NoopMeterProvider());
return noopProvider->GetMeter(std::string(name), std::string(kMeterVersion));
// Mirrors the noop tracer above: instruments created from it are inert.
return noopMeter(name);
}
[[nodiscard]] opentelemetry::nostd::shared_ptr<trace_api::Span>
@@ -727,6 +724,16 @@ public:
} // namespace
opentelemetry::nostd::shared_ptr<metrics_api::Meter>
noopMeter(std::string_view name)
{
// One provider for the process: it holds a single inert meter, so nothing
// is gained by building another.
static auto const provider = opentelemetry::nostd::shared_ptr<metrics_api::MeterProvider>(
new metrics_api::NoopMeterProvider());
return provider->GetMeter(std::string(name), std::string(kMeterVersion));
}
opentelemetry::exporter::otlp::OtlpHttpExporterOptions
makeTraceExporterOptions(Telemetry::Setup const& setup)
{

View File

@@ -0,0 +1,208 @@
#ifdef XRPL_ENABLE_TELEMETRY
#include <xrpl/beast/insight/Collector.h>
#include <xrpl/beast/insight/Gauge.h>
#include <xrpl/beast/insight/Hook.h>
#include <xrpl/beast/insight/OTelCollector.h>
#include <xrpl/beast/utility/Journal.h>
#include <gtest/gtest.h>
#include <opentelemetry/metrics/meter_provider.h>
#include <opentelemetry/metrics/provider.h>
#include <opentelemetry/nostd/function_ref.h>
#include <opentelemetry/nostd/shared_ptr.h>
#include <opentelemetry/sdk/metrics/instruments.h>
#include <opentelemetry/sdk/metrics/meter_provider.h>
#include <opentelemetry/sdk/metrics/meter_provider_factory.h>
#include <opentelemetry/sdk/metrics/metric_reader.h>
#include <chrono>
#include <cstddef>
#include <memory>
#include <string>
#include <utility>
namespace beast::insight {
namespace metrics_api = opentelemetry::metrics;
namespace metrics_sdk = opentelemetry::sdk::metrics;
/**
* A MetricReader that collects only when the test asks it to.
*
* The SDK ships only PeriodicExportingMetricReader, whose background thread
* would make these tests depend on timing. MetricReader::Collect() is public
* and synchronous, so a minimal subclass lets a test drive one collection pass
* on the calling thread. That pass is what invokes an observable gauge's
* callback, which is the only path that reaches the collector's hooks.
*
* @code
* auto reader = std::make_shared<ManualMetricReader>();
* provider->AddMetricReader(reader);
* reader->collectOnce(); // runs every registered observable callback
* @endcode
*/
class ManualMetricReader : public metrics_sdk::MetricReader
{
public:
/**
* @brief Run exactly one collection pass, discarding the metric data.
*
* The tests assert on hook side effects, not on exported points, so the
* callback returns true without inspecting what it was handed.
*/
void
collectOnce()
{
Collect([](metrics_sdk::ResourceMetrics&) { return true; });
}
metrics_sdk::AggregationTemporality
GetAggregationTemporality(metrics_sdk::InstrumentType) const noexcept override
{
return metrics_sdk::AggregationTemporality::kCumulative;
}
bool
OnForceFlush(std::chrono::microseconds) noexcept override
{
return true;
}
bool
OnShutDown(std::chrono::microseconds) noexcept override
{
return true;
}
};
/**
* Installs a real SDK MeterProvider so observable gauges actually fire.
*
* OTelCollector takes its Meter from the global provider. Under the default
* noop provider an observable gauge's callback is never invoked, so a hook
* test would pass whatever the collector did. The fixture swaps in an SDK
* provider with a ManualMetricReader and restores the previous global provider
* afterwards, so it leaks no state into other telemetry tests in this binary.
*/
class OTelCollectorHooks : public ::testing::Test
{
protected:
void
SetUp() override
{
previous_ = metrics_api::Provider::GetMeterProvider();
reader_ = std::make_shared<ManualMetricReader>();
auto provider = metrics_sdk::MeterProviderFactory::Create();
provider->AddMetricReader(reader_);
provider_ = std::shared_ptr<metrics_sdk::MeterProvider>(std::move(provider));
metrics_api::Provider::SetMeterProvider(
opentelemetry::nostd::shared_ptr<metrics_api::MeterProvider>(provider_));
}
void
TearDown() override
{
metrics_api::Provider::SetMeterProvider(previous_);
provider_.reset();
reader_.reset();
}
/**
* @brief Build a collector, plus the armed gauge that drives its hooks.
*
* A collection pass only reaches the hooks through an observable gauge's
* callback, and a gauge is armed by onCollectionReady(), so every test
* needs both. The gauge is returned because dropping it would unregister
* the callback.
*
* Each test builds its own collector: the hook debounce is keyed to the
* time of the last invocation, which starts unset, so the first collection
* on a fresh collector always runs the hooks.
*/
static std::pair<Collector::ptr, Gauge>
makeArmedCollector()
{
auto collector = OTelCollector::New(
"http://127.0.0.1:4318/v1/metrics",
"",
"test-instance",
"xrpld",
"test",
Journal(Journal::getNullSink()));
auto gauge = collector->makeGauge("hook_test_gauge");
collector->onCollectionReady();
return {std::move(collector), std::move(gauge)};
}
opentelemetry::nostd::shared_ptr<metrics_api::MeterProvider> previous_;
std::shared_ptr<ManualMetricReader> reader_;
std::shared_ptr<metrics_sdk::MeterProvider> provider_;
};
// ---------------------------------------------------------------------------
// 1. A hook that is still alive runs on a collection pass.
// This is the registration path: makeHook() puts the hook on the
// collector's list, and an observable gauge callback invokes it. Without
// this, a hook that is never registered is indistinguishable from one that
// is registered and skipped.
// ---------------------------------------------------------------------------
TEST_F(OTelCollectorHooks, live_hook_runs_once_per_collection)
{
auto [collector, gauge] = makeArmedCollector();
std::size_t calls = 0;
auto const hook = collector->makeHook([&calls] { ++calls; });
reader_->collectOnce();
EXPECT_EQ(calls, 1u);
}
// ---------------------------------------------------------------------------
// 2. A hook destroyed before the collection pass is skipped, not called.
// The collector holds weak references, so the destroyed hook's entry locks
// to null. Asserting zero (not "did not crash") is what makes this a real
// check: a stale entry that was still followed would run the handler and
// increment the counter through freed memory.
// ---------------------------------------------------------------------------
TEST_F(OTelCollectorHooks, destroyed_hook_is_skipped)
{
auto [collector, gauge] = makeArmedCollector();
std::size_t calls = 0;
{
auto const hook = collector->makeHook([&calls] { ++calls; });
}
reader_->collectOnce();
EXPECT_EQ(calls, 0u);
}
// ---------------------------------------------------------------------------
// 3. Destroying one hook leaves its siblings registered.
// Guards the pruning step: removeExpiredHooks() erases by expiry rather
// than by address, so an over-broad predicate would drop live hooks too and
// silently stop their metrics updating.
// ---------------------------------------------------------------------------
TEST_F(OTelCollectorHooks, destroying_one_hook_keeps_the_others)
{
auto [collector, gauge] = makeArmedCollector();
std::size_t kept = 0;
std::size_t dropped = 0;
auto const keptHook = collector->makeHook([&kept] { ++kept; });
{
auto const droppedHook = collector->makeHook([&dropped] { ++dropped; });
}
reader_->collectOnce();
EXPECT_EQ(kept, 1u);
EXPECT_EQ(dropped, 0u);
}
} // namespace beast::insight
#endif // XRPL_ENABLE_TELEMETRY

View File

@@ -0,0 +1,158 @@
/**
* @file NodeIdentity.cpp
* GTest unit tests for the wallet database's node-identity storage.
*
* Three functions share one table, `NodeIdentity`, and the split between them
* is what the telemetry startup order depends on: `readNodeIdentity()` only
* reads, `storeNodeIdentity()` only writes, and `getNodeIdentity()` reads then
* writes a fresh key when the table is empty. `xrpld` resolves its identity
* before the Application exists and persists it later, so the store step has
* to be callable on its own and has to be idempotent-by-read: a second run
* must return the first run's key, not a new one.
*
* Each test gets its own database file in a temporary directory, so nothing
* here depends on order or on the developer's data directory.
*/
#include <xrpl/basics/Log.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/rdb/DatabaseCon.h>
#include <xrpl/server/Wallet.h>
#include <gtest/gtest.h>
#include <filesystem>
#include <memory>
#include <string>
#include <utility>
using namespace xrpl;
namespace {
/**
* A wallet database in its own temporary directory, removed on destruction.
*
* `makeTestWalletDB()` creates the schema, so every fixture starts with an
* empty `NodeIdentity` table.
*/
class TempWalletDb
{
public:
explicit TempWalletDb(std::string const& name)
: dir_(std::filesystem::temp_directory_path() / ("xrpl-node-identity-" + name))
{
std::filesystem::remove_all(dir_);
std::filesystem::create_directories(dir_);
DatabaseCon::Setup setup;
setup.dataDir = dir_;
db_ = makeTestWalletDB(setup, "wallet.db", beast::Journal{beast::Journal::getNullSink()});
}
~TempWalletDb()
{
db_.reset();
std::error_code ec;
std::filesystem::remove_all(dir_, ec);
}
TempWalletDb(TempWalletDb const&) = delete;
TempWalletDb&
operator=(TempWalletDb const&) = delete;
[[nodiscard]] DatabaseCon&
operator*() const noexcept
{
return *db_;
}
private:
std::filesystem::path dir_;
std::unique_ptr<DatabaseCon> db_;
};
} // namespace
TEST(WalletNodeIdentity, store_then_read_returns_the_same_pair)
{
// The store step exists so a key minted before the Application is built
// can be persisted afterwards. Reading it back must give the same pair, or
// the two halves of one run report two identities.
TempWalletDb wallet("store-then-read");
auto const minted = randomKeyPair(KeyType::Secp256k1);
{
auto db = (*wallet).checkoutDb();
ASSERT_FALSE(readNodeIdentity(*db).has_value()) << "a fresh wallet must hold no identity";
storeNodeIdentity(*db, minted);
}
auto db = (*wallet).checkoutDb();
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
EXPECT_EQ(stored->first, minted.first);
EXPECT_EQ(stored->second, minted.second);
}
TEST(WalletNodeIdentity, store_does_not_replace_an_existing_identity)
{
// getNodeIdentity() is the read-or-mint path and must keep the first key,
// so a restart does not change the node's identity on the network. The
// stored pair wins over anything a later caller offers.
TempWalletDb wallet("no-replace");
auto db = (*wallet).checkoutDb();
auto const first = getNodeIdentity(*db);
auto const other = randomKeyPair(KeyType::Secp256k1);
ASSERT_NE(first.first, other.first)
<< "the two pairs must differ for this test to mean anything";
storeNodeIdentity(*db, other);
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
EXPECT_EQ(stored->first, first.first);
EXPECT_EQ(getNodeIdentity(*db).first, first.first);
}
TEST(WalletNodeIdentity, clear_then_store_installs_the_new_pair)
{
// --newnodeid clears the row and then persists the freshly minted pair.
// Both steps are needed: clearing alone would leave the node with no
// stored identity at all.
TempWalletDb wallet("clear-then-store");
auto db = (*wallet).checkoutDb();
auto const first = getNodeIdentity(*db);
auto const replacement = randomKeyPair(KeyType::Secp256k1);
ASSERT_NE(first.first, replacement.first);
clearNodeIdentity(*db);
EXPECT_FALSE(readNodeIdentity(*db).has_value()) << "clear must leave the table empty";
storeNodeIdentity(*db, replacement);
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
EXPECT_EQ(stored->first, replacement.first);
EXPECT_EQ(stored->second, replacement.second);
}
TEST(WalletNodeIdentity, get_mints_and_persists_when_the_table_is_empty)
{
// The mint path must persist, not just return: a second call has to give
// the same key. This is the property --newnodeid relies on to be
// meaningful, and the one a caller that only reads would break.
TempWalletDb wallet("mint-and-persist");
auto db = (*wallet).checkoutDb();
auto const minted = getNodeIdentity(*db);
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value()) << "getNodeIdentity() must persist what it mints";
EXPECT_EQ(stored->first, minted.first);
EXPECT_EQ(getNodeIdentity(*db).first, minted.first);
}

View File

@@ -193,10 +193,7 @@ public:
opentelemetry::nostd::shared_ptr<opentelemetry::metrics::Meter>
getMeter(std::string_view name) override
{
static auto noopProvider =
opentelemetry::nostd::shared_ptr<opentelemetry::metrics::MeterProvider>(
new opentelemetry::metrics::NoopMeterProvider());
return noopProvider->GetMeter(std::string(name), std::string(kMeterVersion));
return noopMeter(name);
}
opentelemetry::nostd::shared_ptr<opentelemetry::trace::Span>

View File

@@ -82,9 +82,11 @@
#include <xrpl/protocol/BuildInfo.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h> // IWYU pragma: keep
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Serializer.h>
#include <xrpl/protocol/SystemParameters.h> // IWYU pragma: keep
#include <xrpl/protocol/jss.h>
@@ -152,12 +154,12 @@ fixConfigPorts(Config& config, Endpoints const& endpoints);
* expose the Setup it parsed, so those keys are read here a second time. The
* export cadence is the registry's own (10 s) and is not read from config.
*
* @param config The loaded server config.
* @param nodePublicKey Node key resolved in Main.cpp; empty on a first boot.
* @param config The loaded server config.
* @param nodeKey Base58 node public key, resolved before construction.
* @return Options for MetricsRegistry's constructor.
*/
static telemetry::MetricsRegistry::Options
makeMetricsRegistryOptions(Config const& config, std::optional<std::string> const& nodePublicKey)
makeMetricsRegistryOptions(Config const& config, std::string const& nodeKey)
{
auto const& section = config.section("telemetry");
telemetry::MetricsRegistry::Options options;
@@ -179,12 +181,12 @@ makeMetricsRegistryOptions(Config const& config, std::optional<std::string> cons
// service_instance_id is the label every dashboard filters $node on.
// xrpl.node.id carries the same key and cannot be overridden by config.
// Both come from the key Main.cpp resolved before construction, the same
// source Telemetry's own metrics resource uses.
// Both come from the identity resolveNodeIdentity() decided before
// construction, the same source Telemetry's own metrics resource uses.
set(options.serviceInstanceId, "service_instance_id", section);
if (options.serviceInstanceId.empty())
options.serviceInstanceId = nodePublicKey.value_or("");
options.nodeId = nodePublicKey.value_or("");
options.serviceInstanceId = nodeKey;
options.nodeId = nodeKey;
// xrpl.network.id, and the xrpl.network.type label the registry derives
// from it. Without this the collector's insert rule fills in its own
@@ -281,6 +283,13 @@ public:
beast::Journal journal_;
std::unique_ptr<perf::PerfLog> perfLog_;
/**
* This node's keypair, resolved before construction by
* resolveNodeIdentity() and persisted by setup(). Declared before
* telemetry_ because that builds resource attributes from it, and they are
* immutable once built.
*/
std::pair<PublicKey, SecretKey> nodeIdentity_;
std::unique_ptr<telemetry::Telemetry> telemetry_;
/**
* OTel metrics registry for gap-fill metrics (counters, histograms,
@@ -316,7 +325,6 @@ public:
NodeCache tempNodeCache_;
CachedSLEs cachedSLEs_;
std::unique_ptr<NetworkIDService> networkIDService_;
std::optional<std::pair<PublicKey, SecretKey>> nodeIdentity_;
ValidatorKeys const validatorKeys_;
std::unique_ptr<resource::Manager> resourceManager_;
@@ -397,7 +405,7 @@ public:
std::unique_ptr<Config> config,
std::unique_ptr<Logs> logs,
std::unique_ptr<TimeKeeper> timeKeeper,
std::optional<std::string> const& nodePublicKey)
std::pair<PublicKey, SecretKey> const& resolvedIdentity)
: BasicApp(numberOfThreads(*config))
, config_(std::move(config))
, logs_(std::move(logs))
@@ -411,15 +419,16 @@ public:
*this,
logs_->journal("PerfLog"),
[this] { signalStop("PerfLog"); }))
, nodeIdentity_(resolvedIdentity)
// Telemetry publishes the MeterProvider on construction, so it must
// precede collectorManager_ below and every subsystem that creates an
// instrument. Its resource is immutable, so the instance id has to be
// supplied now; empty means this run reports none.
// supplied now, from the identity resolved above.
, telemetry_(
telemetry::makeTelemetry(
telemetry::makeTelemetrySetup(
config_->section("telemetry"),
nodePublicKey.value_or(""),
toBase58(TokenType::NodePublic, nodeIdentity_.first),
build_info::getVersionString(),
config_->networkId),
logs_->journal("Telemetry")))
@@ -432,7 +441,9 @@ public:
telemetry_->isEnabled(),
*this,
logs_->journal("MetricsRegistry"),
makeMetricsRegistryOptions(*config_, nodePublicKey)))
makeMetricsRegistryOptions(
*config_,
toBase58(TokenType::NodePublic, nodeIdentity_.first))))
, txMaster_(*this)
, collectorManager_(makeCollectorManager(
@@ -710,10 +721,7 @@ public:
std::pair<PublicKey, SecretKey> const&
nodeIdentity() override
{
if (nodeIdentity_)
return *nodeIdentity_;
logicError("Accessing Application::nodeIdentity() before it is initialized.");
return nodeIdentity_;
}
std::optional<PublicKey const>
@@ -1439,12 +1447,15 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
return false;
}
nodeIdentity_ = getNodeIdentity(*this, cmdline);
// Persist the identity resolved before construction, or adopt the one the
// wallet already holds. Telemetry is already reporting the resolved key.
nodeIdentity_ = getNodeIdentity(*this, cmdline, nodeIdentity_);
// The metrics resource was fixed at construction, but the tracer resource is
// built by start() below, so a key minted just now can still reach spans.
// built by start() below, so the stored key still reaches spans if it
// differs from the resolved one.
if (!config_->section("telemetry").exists("service_instance_id"))
telemetry_->setServiceInstanceId(toBase58(TokenType::NodePublic, nodeIdentity_->first));
telemetry_->setServiceInstanceId(toBase58(TokenType::NodePublic, nodeIdentity_.first));
// xrpl.node.id always carries the node public key. Unlike
// service_instance_id it is not configurable, so traces and metrics keep a
@@ -2466,7 +2477,13 @@ makeApplication(
std::unique_ptr<Logs> logs,
std::unique_ptr<TimeKeeper> timeKeeper)
{
return makeApplication(std::move(config), std::move(logs), std::move(timeKeeper), std::nullopt);
// No identity supplied, so mint one. setup() stores it if the wallet holds
// none, which is what a standalone run and a test Application do anyway.
return makeApplication(
std::move(config),
std::move(logs),
std::move(timeKeeper),
randomKeyPair(KeyType::Secp256k1));
}
std::unique_ptr<Application>
@@ -2474,10 +2491,10 @@ makeApplication(
std::unique_ptr<Config> config,
std::unique_ptr<Logs> logs,
std::unique_ptr<TimeKeeper> timeKeeper,
std::optional<std::string> const& nodePublicKey)
std::pair<PublicKey, SecretKey> const& nodeIdentity)
{
return std::make_unique<ApplicationImp>(
std::move(config), std::move(logs), std::move(timeKeeper), nodePublicKey);
std::move(config), std::move(logs), std::move(timeKeeper), nodeIdentity);
}
void

View File

@@ -175,18 +175,21 @@ makeApplication(
std::unique_ptr<TimeKeeper> timeKeeper);
/**
* Construct the application with a known node public key.
* Construct the application with a known node identity.
*
* Telemetry builds its resource attributes during construction and they are
* immutable, so the base58 node public key must be supplied here. Pass
* std::nullopt when it is unknown; that run reports no instance id. See
* resolveNodePublicKey().
* immutable, so the node keypair must be supplied here. See
* resolveNodeIdentity(), which decides it from the config and command line
* alone; setup() then persists it.
*
* The three-argument overload above mints a keypair, which is what a test
* Application and a standalone run get anyway.
*/
std::unique_ptr<Application>
makeApplication(
std::unique_ptr<Config> config,
std::unique_ptr<Logs> logs,
std::unique_ptr<TimeKeeper> timeKeeper,
std::optional<std::string> const& nodePublicKey);
std::pair<PublicKey, SecretKey> const& resolvedIdentity);
} // namespace xrpl

View File

@@ -807,13 +807,14 @@ run(int argc, char** argv)
if (vm.contains("debug"))
setDebugLogSink(logs->makeSink("Debug", beast::Severity::Trace));
// Telemetry needs the node public key at construction, so read it here
// where a config error can still be reported and the process can exit
// cleanly. getNodeIdentity() in setup() stays authoritative.
std::optional<std::string> nodePublicKey;
// Telemetry stamps the node public key into resources it builds during
// construction, so the identity is decided here, where a malformed
// [node_seed] can still be reported and the process can exit cleanly.
// setup() persists it; see getNodeIdentity().
std::optional<std::pair<PublicKey, SecretKey>> nodeIdentity;
try
{
nodePublicKey = resolveNodePublicKey(*config, vm, logs->journal("Application"));
nodeIdentity = resolveNodeIdentity(*config, vm, logs->journal("Application"));
}
catch (std::exception const& e)
{
@@ -821,14 +822,6 @@ run(int argc, char** argv)
return -1;
}
if (!nodePublicKey)
{
JLOG(logs->journal("Application").warn())
<< "Telemetry: no node identity available yet, so this run reports an empty "
"service.instance.id. Set [telemetry] service_instance_id, or restart once "
"the node key exists.";
}
// Application construction runs member initializers that validate
// config (for example the [telemetry] section) and can throw. A throw
// from a member-initializer list cannot be recovered inside the
@@ -848,7 +841,7 @@ run(int argc, char** argv)
try
{
app = makeApplication(
std::move(config), std::move(logs), std::make_unique<TimeKeeper>(), nodePublicKey);
std::move(config), std::move(logs), std::make_unique<TimeKeeper>(), *nodeIdentity);
}
catch (std::exception const& e)
{

View File

@@ -30,104 +30,88 @@
namespace xrpl {
std::pair<PublicKey, SecretKey>
getNodeIdentity(Application& app, boost::program_options::variables_map const& cmdline)
{
std::optional<Seed> seed;
namespace {
/**
* The seed a configured `[node_seed]` or `--nodeid` names.
*
* @param config The server configuration.
* @param cmdline The command line parameters passed into the application.
* @return The seed, or std::nullopt when neither is configured.
* @throws std::runtime_error if the configured value is malformed.
*/
std::optional<Seed>
configuredSeed(Config const& config, boost::program_options::variables_map const& cmdline)
{
if (cmdline.contains("nodeid"))
{
seed = parseGenericSeed(cmdline["nodeid"].as<std::string>(), false);
auto seed = parseGenericSeed(cmdline["nodeid"].as<std::string>(), false);
if (!seed)
Throw<std::runtime_error>("Invalid 'nodeid' in command line");
return seed;
}
else if (app.config().exists(Sections::kNodeSeed))
{
seed = parseBase58<Seed>(app.config().section(Sections::kNodeSeed).lines().front());
if (config.exists(Sections::kNodeSeed))
{
auto const& lines = config.section(Sections::kNodeSeed).lines();
auto seed = lines.empty() ? std::nullopt : parseBase58<Seed>(lines.front());
if (!seed)
{
Throw<std::runtime_error>(
std::string("Invalid [") + Sections::kNodeSeed + "] in configuration file");
}
return seed;
}
if (seed)
{
auto secretKey = generateSecretKey(KeyType::Secp256k1, *seed);
auto publicKey = derivePublicKey(KeyType::Secp256k1, secretKey);
return {publicKey, secretKey};
}
auto db = app.getWalletDB().checkoutDb();
if (cmdline.contains("newnodeid"))
clearNodeIdentity(*db);
return getNodeIdentity(*db);
return std::nullopt;
}
std::optional<std::string>
resolveNodePublicKey(
Config const& config,
boost::program_options::variables_map const& cmdline,
beast::Journal journal)
/**
* The keypair a seed defines.
*
* @param seed The configured seed.
* @return The derived secp256k1 keypair.
*/
std::pair<PublicKey, SecretKey>
keysFromSeed(Seed const& seed)
{
std::optional<Seed> seed;
bool seedConfigured = false;
if (cmdline.contains("nodeid"))
{
seedConfigured = true;
seed = parseGenericSeed(cmdline["nodeid"].as<std::string>(), false);
}
else if (config.exists(Sections::kNodeSeed))
{
seedConfigured = true;
if (auto const& lines = config.section(Sections::kNodeSeed).lines(); !lines.empty())
seed = parseBase58<Seed>(lines.front());
}
// A configured seed decides the identity outright. A malformed or missing
// one is reported by getNodeIdentity(), which runs later.
if (seedConfigured)
{
if (!seed)
return std::nullopt;
auto const secretKey = generateSecretKey(KeyType::Secp256k1, *seed);
return toBase58(TokenType::NodePublic, derivePublicKey(KeyType::Secp256k1, secretKey));
}
// --newnodeid discards whatever is stored.
if (cmdline.contains("newnodeid"))
return std::nullopt;
auto const secretKey = generateSecretKey(KeyType::Secp256k1, seed);
return {derivePublicKey(KeyType::Secp256k1, secretKey), secretKey};
}
/**
* The stored identity, read without creating or modifying anything.
*
* Runs before the Application, so it opens the wallet itself rather than going
* through getWalletDB(). Three things keep that safe: the file must already
* exist, the init SQL is empty so the schema is never created, and the global
* pragmas are off because they include journal_mode, which rewrites the
* database header. The connection closes before this returns.
*
* @param config The server configuration.
* @param journal Journal for reporting an unreadable database.
* @return The stored keypair, or std::nullopt when there is none to read.
*/
std::optional<std::pair<PublicKey, SecretKey>>
storedIdentity(Config const& config, beast::Journal journal)
{
try
{
auto setup = setupDatabaseCon(config, journal);
// Standalone uses a temporary database, so nothing is persisted and this
// run will mint a fresh key.
// Standalone gets a private temporary database, so there is nothing
// persisted to read and nothing setup() could read back either.
if (setup.standAlone && setup.startUp != StartUpType::Load &&
setup.startUp != StartUpType::LoadFile && setup.startUp != StartUpType::Replay)
{
return std::nullopt;
}
// The global pragmas include journal_mode, which rewrites the database
// header. The wallet is opened without them everywhere else.
setup.useGlobalPragma = false;
// Only read an existing file: SQLite would otherwise create one.
if (std::error_code ec; !std::filesystem::exists(setup.dataDir / kWalletDbName, ec))
{
return std::nullopt;
}
// Empty init SQL: open the existing schema, never create it.
DatabaseCon walletDb{
setup,
kWalletDbName,
@@ -136,8 +120,7 @@ resolveNodePublicKey(
journal};
auto db = walletDb.checkoutDb();
if (auto const stored = readNodeIdentity(*db))
return toBase58(TokenType::NodePublic, stored->first);
return readNodeIdentity(*db);
}
catch (std::exception const& e)
{
@@ -147,4 +130,59 @@ resolveNodePublicKey(
return std::nullopt;
}
} // namespace
std::pair<PublicKey, SecretKey>
resolveNodeIdentity(
Config const& config,
boost::program_options::variables_map const& cmdline,
beast::Journal journal)
{
// A configured seed decides the identity outright, and nothing is stored.
if (auto const seed = configuredSeed(config, cmdline))
return keysFromSeed(*seed);
// --newnodeid discards whatever is stored, so mint now; getNodeIdentity()
// clears the old row and stores this pair.
if (!cmdline.contains("newnodeid"))
{
if (auto const stored = storedIdentity(config, journal))
return *stored;
}
// Nothing to read: a first boot, or a standalone run's temporary database.
// Mint here so telemetry has an identity from construction; setup()
// persists this pair if there is a database to hold it.
return randomKeyPair(KeyType::Secp256k1);
}
std::pair<PublicKey, SecretKey>
getNodeIdentity(
Application& app,
boost::program_options::variables_map const& cmdline,
std::pair<PublicKey, SecretKey> const& resolved)
{
// A configured seed reaches neither the reader nor the writer.
if (cmdline.contains("nodeid") || app.config().exists(Sections::kNodeSeed))
return resolved;
auto db = app.getWalletDB().checkoutDb();
if (cmdline.contains("newnodeid"))
clearNodeIdentity(*db);
// What is stored wins, so a restart keeps the node's identity even if
// another process wrote one between construction and here. Telemetry's
// resources are already built from `resolved`, so on that one run the two
// would disagree; it needs a restart to line up, as the configuration
// reference records.
if (auto const stored = readNodeIdentity(*db))
return *stored;
// Nothing stored, or --newnodeid just cleared it. Persist the pair
// telemetry is already reporting, so both agree from now on.
storeNodeIdentity(*db, resolved);
return resolved;
}
} // namespace xrpl

View File

@@ -16,34 +16,47 @@
namespace xrpl {
/**
* The cryptographic credentials identifying this server instance.
* This server's identity, resolved before the Application exists.
*
* @param app The application object
* @param cmdline The command line parameters passed into the application.
*/
std::pair<PublicKey, SecretKey>
getNodeIdentity(Application& app, boost::program_options::variables_map const& cmdline);
/**
* This server's public key, read without creating or modifying anything.
* Telemetry stamps the node public key into resource attributes that are
* immutable once built, and those resources are built in ApplicationImp's
* member-init list. So the identity has to be decided before construction,
* from the config and the command line alone.
*
* For callers that need the identity before the Application exists, such as
* telemetry building its resource attributes in the member-init list. Derives
* from a configured seed when there is one, otherwise reads the wallet database
* only if it already exists.
*
* getNodeIdentity() remains authoritative and mints a key when none exists.
* Always returns a keypair. It derives one from a configured seed, else reads
* the wallet database if it already exists, else mints one. Nothing is created
* or written here: getNodeIdentity() persists the result once setup() has
* opened the database.
*
* @param config The server configuration.
* @param cmdline The command line parameters passed into the application.
* @param journal Journal for reporting an unreadable database.
* @return The base58-encoded node public key, or std::nullopt if none can be
* read.
* @return This node's keypair.
* @throws std::runtime_error if a configured seed is malformed.
*/
std::optional<std::string>
resolveNodePublicKey(
std::pair<PublicKey, SecretKey>
resolveNodeIdentity(
Config const& config,
boost::program_options::variables_map const& cmdline,
beast::Journal journal);
/**
* The cryptographic credentials identifying this server instance, persisted.
*
* Called from setup(), once the wallet database is open. Stores @p resolved
* when the database holds no identity, and returns whatever the database holds
* when it does.
*
* @param app The application object
* @param cmdline The command line parameters passed into the application.
* @param resolved The keypair resolveNodeIdentity() decided before
* construction, which telemetry is already reporting.
* @return This node's keypair.
*/
std::pair<PublicKey, SecretKey>
getNodeIdentity(
Application& app,
boost::program_options::variables_map const& cmdline,
std::pair<PublicKey, SecretKey> const& resolved);
} // namespace xrpl