Merge branch 'pratik/otel-phase10-workload-validation' into pratik/otel-sync-diagnostics

This commit is contained in:
Pratik Mankawde
2026-08-19 19:54:52 +01:00
10 changed files with 277 additions and 34 deletions

View File

@@ -810,7 +810,7 @@
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"description": "###### What this is:\n*Transitions into each operating mode, per interval.*\n\n###### How it's computed:\n*increase() over each per-mode transition counter, so each point is the number of transitions in that bucket and the series stays correct across an xrpld restart (the counters reset to 0).*\n\n###### Reading it:\n*Zero is healthy; each point is a mode change within that bucket. Brief flaps show up here even when they are too short to appear on Operating Mode (State Timeline), which can only sample state once per scrape.*\n\n###### Healthy range:\n*Few transitions once the node is stable in Full mode.*\n\n###### Watch for:\n*Frequent transitions out of Full, or into Disconnected or Syncing, indicate instability.*\n\n###### Keywords:\n- **Operating mode / server state** *(per node)* \u2014 the node's sync level: Disconnected, Connected, Syncing, Tracking, Full (and Validating/Proposing).\n\n###### Computation boundary:\n*Result: Per node \u2014 each series is one server's own value.*\n*Recorded in xrpld code as a native metric (beast::insight); the collector only forwards it; the Grafana query selects and aggregates it.*\n\n###### Source:\n[NetworkOPs.cpp](https://github.com/XRPLF/rippled/blob/develop/src/xrpld/app/misc/NetworkOPs.cpp)\n\n###### Function:\n`NetworkOPsImp::Stats`\n\n###### References:\n[Operating mode / server state](https://xrpl.org/docs/references/http-websocket-apis/api-conventions/xrpld-server-states) \u00b7 [Telemetry glossary](https://github.com/XRPLF/rippled/blob/develop/docs/telemetry-glossary.md#operating-mode-server-state)",
"description": "###### What this is:\n*Transitions into each operating mode, per interval.*\n\n###### How it's computed:\n*round(increase(...[$__interval])) over each per-mode transition counter. $__interval tiles the buckets exactly, so each bar is the transitions in that bucket and the legend Total is the true count; $__rate_interval would overlap each bucket by one scrape and inflate it (measured +5% at a 36h range, +26% zoomed in). round() removes increase()'s extrapolation, which otherwise reports fractional counts. The series stays correct across an xrpld restart (the counters reset to 0).*\n\n###### Reading it:\n*Zero is healthy; each bar is the mode changes within that bucket. Brief flaps show up here even when they are too short to appear on Operating Mode (State Timeline), which can only sample state once per scrape.*\n\n###### Healthy range:\n*Few transitions once the node is stable in Full mode.*\n\n###### Watch for:\n*Frequent transitions out of Full, or into Disconnected or Syncing, indicate instability.*\n\n###### Keywords:\n- **Operating mode / server state** *(per node)* the node's sync level: Disconnected, Connected, Syncing, Tracking, Full (and Validating/Proposing).\n\n###### Computation boundary:\n*Result: Per node each series is one server's own value.*\n*Recorded in xrpld code as a native metric (beast::insight); the collector only forwards it; the Grafana query selects and aggregates it.*\n\n###### Source:\n[NetworkOPs.cpp](https://github.com/XRPLF/rippled/blob/develop/src/xrpld/app/misc/NetworkOPs.cpp)\n\n###### Function:\n`NetworkOPsImp::Stats`\n\n###### References:\n[Operating mode / server state](https://xrpl.org/docs/references/http-websocket-apis/api-conventions/xrpld-server-states) · [Telemetry glossary](https://github.com/XRPLF/rippled/blob/develop/docs/telemetry-glossary.md#operating-mode-server-state)",
"fieldConfig": {
"defaults": {
"color": {
@@ -824,8 +824,8 @@
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"drawStyle": "bars",
"fillOpacity": 70,
"gradientMode": "none",
"hideFrom": {
"legend": false,
@@ -834,7 +834,7 @@
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"lineWidth": 0,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
@@ -864,7 +864,8 @@
}
]
},
"unit": "short"
"unit": "short",
"decimals": 0
}
},
"gridPos": {
@@ -880,8 +881,8 @@
"multiLane": false
},
"legend": {
"calcs": [],
"displayMode": "list",
"calcs": ["sum", "max"],
"displayMode": "table",
"enableFacetedFilter": false,
"overflow": "ellipsis",
"placement": "bottom",
@@ -901,7 +902,7 @@
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "label_replace(label_join(label_replace(increase(state_accounting_full_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__rate_interval]), \"series\", \"Full\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"expr": "label_replace(label_join(label_replace(round(increase(state_accounting_full_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__interval])), \"series\", \"Full\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"refId": "A"
},
{
@@ -909,7 +910,7 @@
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "label_replace(label_join(label_replace(increase(state_accounting_tracking_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__rate_interval]), \"series\", \"Tracking\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"expr": "label_replace(label_join(label_replace(round(increase(state_accounting_tracking_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__interval])), \"series\", \"Tracking\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"refId": "B"
},
{
@@ -917,7 +918,7 @@
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "label_replace(label_join(label_replace(increase(state_accounting_syncing_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__rate_interval]), \"series\", \"Syncing\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"expr": "label_replace(label_join(label_replace(round(increase(state_accounting_syncing_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__interval])), \"series\", \"Syncing\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"refId": "C"
},
{
@@ -925,7 +926,7 @@
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "label_replace(label_join(label_replace(increase(state_accounting_connected_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__rate_interval]), \"series\", \"Connected\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"expr": "label_replace(label_join(label_replace(round(increase(state_accounting_connected_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__interval])), \"series\", \"Connected\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"refId": "D"
},
{
@@ -933,12 +934,13 @@
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "label_replace(label_join(label_replace(increase(state_accounting_disconnected_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__rate_interval]), \"series\", \"Disconnected\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"expr": "label_replace(label_join(label_replace(round(increase(state_accounting_disconnected_transitions{service_instance_id=~\"$node\", deployment_environment=~\"$deployment_environment\", xrpl_network_type=~\"$xrpl_network_type\", service_name=~\"$service_name\", xrpl_work_item=~\"$xrpl_work_item\", xrpl_branch=~\"$xrpl_branch\", xrpl_node_role=~\"$xrpl_node_role\"}[$__interval])), \"series\", \"Disconnected\", \"\", \"\"), \"xrpl_ident\", \", \", \"service_instance_id\", \"xrpl_branch\", \"xrpl_work_item\"), \"xrpl_ident\", \"[$1]\", \"xrpl_ident\", \"(?:, )*(.*[^, ])(?:, )*\")",
"refId": "E"
}
],
"title": "Operating Mode Transitions",
"type": "timeseries"
"type": "timeseries",
"interval": "1m"
},
{
"datasource": {

View File

@@ -146,6 +146,12 @@ curl -s http://localhost:5015 -d '{"method":"server_info"}' |
| `tls_client_cert` | (empty) | Client cert (PEM) for mutual TLS; empty = one-way TLS |
| `tls_client_key` | (empty) | Private key (PEM) for `tls_client_cert` |
> **Traces and metrics also carry `xrpl.node.id`.** xrpld sets it as a resource
> attribute alongside `service.instance.id`; the value is the node public key
> (base58, begins with `n`). It comes from the node identity unconditionally, so
> it is present even when `[telemetry] service_instance_id` is configured.
> TraceQL filters on it as `resource.xrpl.node.id`.
> **`consensus_trace_strategy` is not validated.** The parser copies the raw
> string through (`TelemetryConfig.cpp:155-156`) and the only equality test in
> the code is `strategy == "attribute"` (`RCLConsensus.cpp:1296`). Any other
@@ -2160,7 +2166,7 @@ Requires `trace_peer=1` in the `[telemetry]` config section.
| Validated Ledger Age | stat | `ledgermaster_validated_ledger_age` | — |
| Published Ledger Age | stat | `ledgermaster_published_ledger_age` | — |
| Operating Mode (Time Share) | timeseries | `rate(state_accounting_X_duration) / sum(rate(all modes))` | — |
| Operating Mode Transitions | timeseries | `increase(state_accounting_*_transitions[$__rate_interval])` | — |
| Operating Mode Transitions | timeseries | `round(increase(state_accounting_*_transitions[$__interval]))` (bars, Min step 1m) | — |
| I/O Latency | timeseries | `histogram_quantile(0.95, ios_latency_milliseconds_bucket)` | — |
| Job Queue Depth | timeseries | `jobq_job_count` | — |
| Ledger Fetch Rate | stat | `rate(ledger_fetches[5m])` | — |

View File

@@ -108,6 +108,7 @@ inline constexpr auto consensus = makeStr("consensus");
inline constexpr auto peer = makeStr("peer");
inline constexpr auto ledger = makeStr("ledger");
inline constexpr auto network = makeStr("network");
inline constexpr auto node = makeStr("node");
inline constexpr auto link = makeStr("link");
} // namespace seg
@@ -117,6 +118,16 @@ namespace attr {
inline constexpr auto networkId = join(join(seg::xrpl, seg::network), makeStr("id"));
inline constexpr auto networkType = join(join(seg::xrpl, seg::network), makeStr("type"));
/**
* Resource attribute `xrpl.node.id` — the node's base58 public key.
*
* Dotted form, like its siblings above, because it is a process-identity
* value stamped once on the OTel resource rather than a per-span attribute.
* It gives traces and metrics a stable per-node key alongside
* `service.instance.id`.
*/
inline constexpr auto nodeId = join(join(seg::xrpl, seg::node), makeStr("id"));
/**
* Canonical shared attrs (rule 5 — <domain>_<field> underscore form).
*

View File

@@ -83,7 +83,8 @@
*
* @note Thread safety: The Telemetry interface is safe for concurrent reads
* (isEnabled, shouldTrace*, getTracer, startSpan) after start() completes.
* setServiceInstanceId() must be called before start() and is not thread-safe.
* setServiceInstanceId() and setNodeId() must be called before start() and
* are not thread-safe.
* The OTel SDK's TracerProvider and Tracer are internally thread-safe.
*/
@@ -193,6 +194,14 @@ public:
*/
std::string serviceInstanceId;
/**
* OTel resource attribute `xrpl.node.id`: the node's base58-encoded
* public key. Always the node identity, never config-supplied, so it
* stays a stable per-node key even when serviceInstanceId is
* overridden by [telemetry] service_instance_id.
*/
std::string nodeId;
/**
* OTLP/HTTP endpoint URL where spans are sent.
*/
@@ -313,6 +322,24 @@ public:
(void)id;
}
/**
* Update the node ID (OTel resource attribute `xrpl.node.id`).
*
* Must be called before start(). A setter is needed for the same reason
* setServiceInstanceId() needs one: the node public key is not available
* when Telemetry is constructed (during the ApplicationImp member
* initializer list), so Application::setup() injects it once
* nodeIdentity_ is known.
*
* @param id The node's base58-encoded public key.
*/
virtual void
setNodeId(std::string const& id)
{
// Default no-op for NullTelemetry implementations.
(void)id;
}
/**
* Initialize the tracing pipeline (exporter, processor, provider).
* Call after construction.

View File

@@ -283,8 +283,8 @@ class TelemetryImpl : public Telemetry
{
/**
* Configuration from the [telemetry] config section.
* Non-const so setServiceInstanceId() can update the instance ID
* before start() creates the OTel resource.
* Non-const so setServiceInstanceId() and setNodeId() can update the
* identity attributes before start() creates the OTel resource.
*/
Setup setup_;
@@ -343,6 +343,12 @@ public:
setup_.serviceInstanceId = id;
}
void
setNodeId(std::string const& id) override
{
setup_.nodeId = id;
}
void
start() override
{
@@ -384,6 +390,7 @@ public:
{std::string(attr::networkId),
static_cast<int64_t>(setup_.networkId)}, // LCOV_EXCL_LINE
{std::string(attr::networkType), setup_.networkType}, // LCOV_EXCL_LINE
{std::string(attr::nodeId), setup_.nodeId}, // LCOV_EXCL_LINE
});
// Configure sampler. Head sampling is fixed at 1.0 (sample everything);
@@ -442,6 +449,8 @@ public:
* during ApplicationImp's member-init list. The metrics resource uses
* setup_.serviceInstanceId from config; it is immutable once the provider
* is built, so a later node-key setServiceInstanceId() does not affect it.
* The same applies to setNodeId(): xrpl.node.id reaches this resource only
* if setup_.nodeId is already populated when the constructor runs.
*/
void
initMetrics()
@@ -479,16 +488,7 @@ public:
auto reader = metrics_sdk::PeriodicExportingMetricReaderFactory::Create(
std::move(metricExporter), readerOpts);
// Metrics resource: same attributes as the tracer resource so metrics
// and traces share one identity. Built here (not shared with start())
// because start() runs later; serviceInstanceId comes from config.
auto resourceAttrs = resource::Resource::Create({
{opentelemetry::semconv::service::kServiceName, setup_.serviceName},
{opentelemetry::semconv::service::kServiceVersion, setup_.serviceVersion},
{opentelemetry::semconv::service::kServiceInstanceId, setup_.serviceInstanceId},
{std::string(attr::networkId), static_cast<int64_t>(setup_.networkId)},
{std::string(attr::networkType), setup_.networkType},
});
auto resourceAttrs = makeMetricsResource();
// Create MeterProvider with the shared resource, then attach reader.
meterProvider_ = metrics_sdk::MeterProviderFactory::Create(

View File

@@ -0,0 +1,162 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/config/BasicConfig.h>
#include <xrpl/telemetry/SpanNames.h>
#include <xrpl/telemetry/Telemetry.h>
#include <gtest/gtest.h>
#include <string>
#include <string_view>
#ifdef XRPL_ENABLE_TELEMETRY
#include <opentelemetry/nostd/variant.h>
#include <opentelemetry/sdk/resource/resource.h>
#endif
/**
* Contract tests for the `xrpl.node.id` resource attribute.
*
* `xrpl.node.id` carries the node's base58 public key on both the trace and
* the metric OTel resource, so traces and metrics resolve to one node. The
* key string is a cross-component contract: the collector, TraceQL queries
* and Grafana dashboards all name it literally, and a silent rename would
* break them with no compile error. These tests pin the literal key, the
* Setup default, and the fact that the value can only arrive through
* Telemetry::setNodeId().
*
* Scope limit: the two production resources are built inside TelemetryImpl
* (trace resource in start(), metric resource in the constructor) and inside
* MetricsRegistry::initExporterAndProvider(). Neither is reachable from this
* binary — TelemetryImpl only exists behind an OTLP/HTTP exporter with
* background export threads, which a unit test must not spin up (see
* GetMeter.cpp), and MetricsRegistry.cpp is not compiled into xrpl_tests in
* the telemetry-enabled build. The resource test below therefore pins the SDK
* contract those three call sites rely on: the exact key, and a std::string
* value landing in the string alternative of the attribute variant rather
* than the bool one.
*/
using namespace xrpl;
using namespace xrpl::telemetry;
TEST(NodeIdResource, attribute_key_is_dotted_resource_form)
{
// The literal the collector, TraceQL and the dashboards all name.
EXPECT_EQ(std::string_view(attr::nodeId), "xrpl.node.id");
// Dotted, not the underscore form used for span attributes.
EXPECT_EQ(std::string_view(attr::nodeId).find('_'), std::string_view::npos);
// Sibling of the other two xrpl.* resource attributes, and distinct
// from both.
EXPECT_EQ(std::string_view(attr::networkId), "xrpl.network.id");
EXPECT_EQ(std::string_view(attr::networkType), "xrpl.network.type");
EXPECT_NE(std::string_view(attr::nodeId), std::string_view(attr::networkId));
EXPECT_NE(std::string_view(attr::nodeId), std::string_view(attr::networkType));
// Built from the shared segments, so the segment additions are exercised
// too rather than only the joined result.
EXPECT_EQ(std::string_view(seg::node), "node");
EXPECT_EQ(std::string_view(seg::xrpl), "xrpl");
}
TEST(NodeIdResource, setup_node_id_defaults_to_empty)
{
// Negative path: nothing has called setNodeId(), so there is no value to
// stamp and the resource builders skip the attribute.
Telemetry::Setup const s;
EXPECT_TRUE(s.nodeId.empty());
EXPECT_EQ(s.nodeId, "");
}
TEST(NodeIdResource, config_parsing_never_populates_node_id)
{
// nodeId is deliberately not config-driven. Even with an explicit
// service_instance_id and a node public key argument, makeTelemetrySetup()
// must leave nodeId empty: Application::setup() is the only writer, via
// setNodeId().
Section section;
section.set("enabled", "1");
section.set("service_instance_id", "custom-id");
auto const setup = makeTelemetrySetup(section, "nHUtest123", "2.0.0", 1);
EXPECT_EQ(setup.serviceInstanceId, "custom-id");
EXPECT_TRUE(setup.nodeId.empty());
}
TEST(NodeIdResource, set_node_id_on_disabled_path_is_inert)
{
// The disabled build/config path takes the base-class no-op. Calling it
// must be safe and must not change any observable state.
Telemetry::Setup setup;
setup.enabled = false;
beast::Journal::Sink& sink = beast::Journal::getNullSink();
beast::Journal const journal(sink);
auto telemetry = makeTelemetry(setup, journal);
ASSERT_NE(telemetry, nullptr);
telemetry->setNodeId("nHUtest123");
EXPECT_FALSE(telemetry->isEnabled());
EXPECT_FALSE(telemetry->shouldTraceRpc());
EXPECT_FALSE(telemetry->shouldTraceTransactions());
EXPECT_FALSE(telemetry->shouldTraceConsensus());
EXPECT_FALSE(telemetry->shouldTracePeer());
EXPECT_FALSE(telemetry->shouldTraceLedger());
EXPECT_EQ(telemetry->getConsensusTraceStrategy(), "deterministic");
}
#ifdef XRPL_ENABLE_TELEMETRY
TEST(NodeIdResource, resource_carries_node_id_as_a_string)
{
namespace otel_resource = opentelemetry::sdk::resource;
// A base58 node public key: 'n' prefix, 52 characters.
std::string const nodeId = "n9MozjnGB3tpULewtTsVtuudg5JqYFyV3QFdAtVLzJaxHcBaxuXM";
ASSERT_EQ(nodeId.size(), 52u);
otel_resource::ResourceAttributes attrs;
// std::string, never a string literal: the attribute variant's
// char-const* overload binds to bool, which would record `true`.
attrs[std::string(attr::nodeId)] = nodeId;
auto const resource = otel_resource::Resource::Create(attrs);
auto const& out = resource.GetAttributes();
auto const it = out.find("xrpl.node.id");
ASSERT_NE(it, out.end());
// The string alternative, not bool — the pitfall the call sites guard.
ASSERT_TRUE(opentelemetry::nostd::holds_alternative<std::string>(it->second));
EXPECT_FALSE(opentelemetry::nostd::holds_alternative<bool>(it->second));
EXPECT_EQ(opentelemetry::nostd::get<std::string>(it->second), nodeId);
}
TEST(NodeIdResource, resource_omits_node_id_when_it_was_never_set)
{
namespace otel_resource = opentelemetry::sdk::resource;
// Negative path: the call sites only assign when the value is non-empty,
// so an unset node ID leaves the key off the resource entirely rather
// than stamping a blank one.
Telemetry::Setup const setup;
ASSERT_TRUE(setup.nodeId.empty());
otel_resource::ResourceAttributes attrs;
if (!setup.nodeId.empty())
attrs[std::string(attr::nodeId)] = setup.nodeId;
auto const resource = otel_resource::Resource::Create(attrs);
auto const& out = resource.GetAttributes();
EXPECT_EQ(out.find("xrpl.node.id"), out.end());
// The SDK still merges in its own defaults, so the absence above is a
// real absence and not an empty map.
EXPECT_FALSE(out.empty());
}
#endif // XRPL_ENABLE_TELEMETRY

View File

@@ -15,6 +15,7 @@ TEST(TelemetryConfig, setup_defaults)
EXPECT_EQ(s.serviceName, "xrpld");
EXPECT_TRUE(s.serviceVersion.empty());
EXPECT_TRUE(s.serviceInstanceId.empty());
EXPECT_TRUE(s.nodeId.empty());
EXPECT_EQ(s.exporterEndpoint, "http://localhost:4318/v1/traces");
EXPECT_FALSE(s.useTls);
EXPECT_TRUE(s.tlsCertPath.empty());

View File

@@ -1394,6 +1394,11 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
if (!config_->section("telemetry").exists("service_instance_id"))
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
// stable per-node key whatever [telemetry] says.
telemetry_->setNodeId(toBase58(TokenType::NodePublic, nodeIdentity_->first));
// Create the OTel MetricsRegistry for gap-fill metrics (counters,
// histograms, observable gauges). It must exist before startTelemetry(),
// which starts the metrics half of the pipeline.
@@ -1748,7 +1753,13 @@ ApplicationImp::startTelemetry() const
if (instanceId.empty() && nodeIdentity_)
instanceId = toBase58(TokenType::NodePublic, nodeIdentity_->first);
metricsRegistry_->start(endpoint, instanceId);
// The node public key also goes on its own resource attribute,
// xrpl.node.id, which config cannot override.
std::string nodeId;
if (nodeIdentity_)
nodeId = toBase58(TokenType::NodePublic, nodeIdentity_->first);
metricsRegistry_->start(endpoint, instanceId, nodeId);
}
}

View File

@@ -68,6 +68,7 @@
#include <xrpl/server/LoadFeeTrack.h>
#include <xrpl/server/NetworkOPs.h>
#include <xrpl/telemetry/GetObjectMetricNames.h>
#include <xrpl/telemetry/SpanNames.h>
#include <opentelemetry/context/context.h>
#include <opentelemetry/exporters/otlp/otlp_http_metric_exporter_factory.h>
@@ -266,14 +267,17 @@ MetricsRegistry::~MetricsRegistry()
}
void
MetricsRegistry::start(std::string const& endpoint, std::string const& instanceId)
MetricsRegistry::start(
std::string const& endpoint,
std::string const& instanceId,
std::string const& nodeId)
{
#ifdef XRPL_ENABLE_TELEMETRY
if (!enabled_)
return;
JLOG(journal_.info()) << "MetricsRegistry: starting, endpoint=" << endpoint
<< ", instanceId=" << instanceId;
<< ", instanceId=" << instanceId << ", nodeId=" << nodeId;
// Rule for anything added below: this phase may create only instruments
// whose recording is PUSHED from app code -- counters and histograms. An
@@ -283,13 +287,14 @@ MetricsRegistry::start(std::string const& endpoint, std::string const& instanceI
// belongs in startAsyncGauges(), 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.
initExporterAndProvider(endpoint, instanceId);
initExporterAndProvider(endpoint, instanceId, nodeId);
initSyncInstruments();
JLOG(journal_.info()) << "MetricsRegistry: provider and instruments ready";
#else
(void)endpoint;
(void)instanceId;
(void)nodeId;
(void)enabled_;
#endif // XRPL_ENABLE_TELEMETRY
}
@@ -320,7 +325,10 @@ MetricsRegistry::startAsyncGauges()
#ifdef XRPL_ENABLE_TELEMETRY
void
MetricsRegistry::initExporterAndProvider(std::string const& endpoint, std::string const& instanceId)
MetricsRegistry::initExporterAndProvider(
std::string const& endpoint,
std::string const& instanceId,
std::string const& nodeId)
{
// Configure OTLP/HTTP metric exporter.
otlp_http::OtlpHttpMetricExporterOptions exporterOpts;
@@ -344,6 +352,11 @@ MetricsRegistry::initExporterAndProvider(std::string const& endpoint, std::strin
attrs[opentelemetry::semconv::service::kServiceName] = std::string("xrpld");
if (!instanceId.empty())
attrs[opentelemetry::semconv::service::kServiceInstanceId] = instanceId;
// xrpl.node.id: the same per-node key the trace resource carries, so
// metrics and traces resolve to one node. std::string for the same
// variant reason as service.name above.
if (!nodeId.empty())
attrs[std::string(attr::nodeId)] = nodeId;
auto resourceAttrs = otel_resource::Resource::Create(attrs);
// Build a view registry with explicit buckets for the duration

View File

@@ -295,9 +295,15 @@ public:
* attribute. When non-empty, Prometheus metrics
* carry a service_instance_id label for per-node
* filtering.
* @param nodeId Value for the xrpl.node.id resource attribute (the
* node's base58 public key). When non-empty, metrics
* carry the same per-node key that traces do.
*/
void
start(std::string const& endpoint, std::string const& instanceId = {});
start(
std::string const& endpoint,
std::string const& instanceId = {},
std::string const& nodeId = {});
/**
* Register the pull-model observable instruments — the second startup
@@ -1063,9 +1069,13 @@ private:
*
* @param endpoint OTLP/HTTP metrics endpoint URL.
* @param instanceId service.instance.id resource attribute (may be empty).
* @param nodeId xrpl.node.id resource attribute (may be empty).
*/
void
initExporterAndProvider(std::string const& endpoint, std::string const& instanceId);
initExporterAndProvider(
std::string const& endpoint,
std::string const& instanceId,
std::string const& nodeId);
/**
* Create the synchronous instruments (RPC and job-queue counters and