Skip to content

Commit ee368fc

Browse files
Add new XRPL collector
1 parent fa62188 commit ee368fc

File tree

8 files changed

+243
-6
lines changed

8 files changed

+243
-6
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,78 @@
11
# Blockchain RPC Exporter
2+
23
The exporter is used to scrape metrics from blockchain RPC endpoints. The purpose of this exporter is to perform black-box testing on RPC endpoints.
4+
35
## Metrics
6+
47
Exporter currently supports all EVM-compatible chains. In addition, there is limited support for the following chains:
8+
59
- Cardano (wss)
610
- Conflux (wss)
711
- Solana (https & wss)
812
- Bitcoin (https)
913
- Dogecoin (https)
1014
- Filecoin (https)
1115
- Starknet (https)
16+
- Aptos (https)
17+
- XRPL (https)
1218

1319
## Available Metrics
1420

1521
# Disclaimer
22+
1623
Please note that this tool is in the early development stage and should not be used to influence critical business decisions.
1724
The project in its current form suits our short-term needs and will receive limited support. We encourage you to fork the project and extend it with additional functionality you might need.
1825

1926
## Development
27+
2028
You should install [pre-commit](https://pre-commit.com/) so that automated linting and formatting checks are performed before each commit.
2129

2230
Run:
31+
2332
```bash
2433
pip install pre-commit
2534
pre-commit install
2635
```
36+
2737
### Running locally
38+
2839
1. Make sure you have python3 installed (>3.11)
2940
2. Set up your python environment
41+
3042
```bash
3143
pip3 install virtualenv
3244
virtualenv venv
3345
source venv/bin/activate
3446
pip install -r requirements.txt
3547
pip install -r requirements-dev.txt
3648
```
49+
3750
1. Generate valid exporter config and validation file. For example see [config example](config/exporter_example/config.yml) and [validation example](config/exporter_example/validation.yml).
3851
2. Export paths of generated configuration files relative to `src/exporter.py`:
52+
3953
```bash
4054
export VALIDATION_FILE_PATH="validation.yml" # For example if we saved validation config file in src/validation.yml
4155
export CONFIG_FILE_PATH="config.yml" # For example if we saved config file in src/config.yml
4256
```
57+
4358
3. Finally you can run the exporter
59+
4460
```bash
4561
python exporter.py
4662
```
63+
4764
### Run with docker-compose
65+
4866
1. Generate valid exporter config and validation file. For example see [config example](config/exporter_example/config.yml) and [validation example](config/exporter_example/validation.yml).
4967
2. Export paths of generated configuration files relative to `docker-compose.yml`:
68+
5069
```bash
5170
export VALIDATION_FILE_PATH="src/validation.yml" # For example if we saved validation config file in src/validation.yml
5271
export CONFIG_FILE_PATH="src/config.yml" # For example if we saved config file in src/config.yml
5372
```
73+
5474
3. Execute
75+
5576
```bash
5677
docker-compose build
5778
docker-compose up
@@ -61,28 +82,39 @@ curl localhost:9090 # Prometheus
6182
```
6283

6384
### Testing
85+
6486
Testing is performed using [pytest](https://docs.pytest.org/) run by [coverage.py](https://coverage.readthedocs.io/) to generate test coverage reporting.
6587
[pylint](https://pylint.readthedocs.io/) is used to lint the pyhton code.
6688
These dependencies can be found in the [requirements-dev.txt](requirements-dev.txt) file. Unit testing and linting is performed on every commit push to the repository. 90% test coverage and no linter errors/warnings are a requirement for the tests to pass.
6789

6890
#### Testing Locally (venv)
91+
6992
Tests can be run locally in the virtual environment.
93+
7094
1. Run the unit tests with coverage.py from within the `src` directory.
95+
7196
```bash
7297
coverage run --branch -m pytest
7398
```
99+
74100
2. Generate the coverage report. To view the report open the generated `index.html` file in a browser.
101+
75102
```bash
76103
coverage html
77104
```
105+
78106
3. Run the linter to find any errors/warnings.
107+
79108
```bash
80109
pylint src/*py
81110
```
82111

83112
#### Testing Locally (docker)
113+
84114
The tests and linter can be run using docker by building the `test` docker stage.
115+
85116
1. Build the `test` stage in the `Dockerfile`.
117+
86118
```bash
87119
docker build --target test .
88120
```

src/collectors.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,74 @@ def client_version(self):
500500
def latency(self):
501501
"""Returns connection latency."""
502502
return self.interface.latest_query_latency
503+
504+
505+
class XRPLCollector():
506+
"""A collector to fetch information about XRP Ledger endpoints."""
507+
508+
def __init__(self, url, labels, chain_id, **client_parameters):
509+
self.labels = labels
510+
self.chain_id = chain_id
511+
self.interface = HttpsInterface(url, client_parameters.get('open_timeout'),
512+
client_parameters.get('ping_timeout'))
513+
self._logger_metadata = {
514+
'component': 'XRPLCollector',
515+
'url': strip_url(url)
516+
}
517+
self.ledger_closed_payload = {
518+
'method': 'ledger_closed',
519+
'params': [{}] # Required empty object in params array
520+
}
521+
self.server_info_payload = {
522+
'method': 'server_info',
523+
'params': [{}] # Required empty object in params array
524+
}
525+
526+
def alive(self):
527+
"""Returns true if endpoint is alive, false if not."""
528+
return self.interface.cached_json_rpc_post(
529+
self.ledger_closed_payload, non_rpc_response=True) is not None
530+
531+
def block_height(self):
532+
"""Returns latest block height (ledger index)."""
533+
response = self.interface.cached_json_rpc_post(
534+
self.ledger_closed_payload, non_rpc_response=True)
535+
if response is None:
536+
return None
537+
538+
# For XRPL, the response will be the whole JSON object
539+
if isinstance(response, dict) and 'result' in response:
540+
result = response['result']
541+
return validate_dict_and_return_key_value(
542+
result, 'ledger_index', self._logger_metadata)
543+
return None
544+
545+
def client_version(self):
546+
"""Gets build version from server_info."""
547+
response = self.interface.cached_json_rpc_post(
548+
self.server_info_payload, non_rpc_response=True)
549+
if response is None:
550+
return None
551+
552+
# For XRPL, the response will be the whole JSON object
553+
if isinstance(response, dict) and 'result' in response:
554+
result = response['result']
555+
556+
if 'info' in result:
557+
info = result['info']
558+
559+
version = validate_dict_and_return_key_value(
560+
info, 'build_version', self._logger_metadata, stringify=True)
561+
562+
# If build_version is not found, try libxrpl_version
563+
if version is None:
564+
version = validate_dict_and_return_key_value(
565+
info, 'libxrpl_version', self._logger_metadata, stringify=True)
566+
567+
if version is not None:
568+
return {"client_version": version}
569+
return None
570+
571+
def latency(self):
572+
"""Returns connection latency."""
573+
return self.interface.latest_query_latency

src/configuration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def endpoints(self):
4545
def _load_configuration(self):
4646
supported_collectors = ('evm', 'evmhttp', 'cardano', 'conflux', 'solana',
4747
'bitcoin', 'doge', 'filecoin', 'starknet', 'aptos',
48-
'tron')
48+
'tron', 'xrpl')
4949

5050
configuration_schema = Schema({
5151
'blockchain':

src/interfaces.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,26 +70,32 @@ def _return_and_validate_request(self, method='GET', payload=None, params=None):
7070
**self._logger_metadata)
7171
return None
7272

73-
def json_rpc_post(self, payload):
73+
def json_rpc_post(self, payload, non_rpc_response=None):
7474
"""Checks the validity of a successful json-rpc response. If any of the
7575
validations fail, the method returns type None. """
7676
response = self._return_and_validate_request(method='POST', payload=payload)
7777
if response is not None:
78-
result = return_and_validate_rpc_json_result(
79-
response, self._logger_metadata)
78+
# Use REST validation instead of RPC validation to handle non standard RPC responses such as XRPL
79+
if non_rpc_response:
80+
result = return_and_validate_rest_api_json_result(
81+
response, self._logger_metadata)
82+
else:
83+
result = return_and_validate_rpc_json_result(
84+
response, self._logger_metadata)
85+
8086
if result is not None:
8187
return result
8288
return None
8389

84-
def cached_json_rpc_post(self, payload: dict):
90+
def cached_json_rpc_post(self, payload: dict, non_rpc_response=None):
8591
"""Calls json_rpc_post and stores the result in in-memory cache."""
8692
cache_key = f"rpc:{str(payload)}"
8793

8894
if self.cache.is_cached(cache_key):
8995
return_value = self.cache.retrieve_key_value(cache_key)
9096
return return_value
9197

92-
value = self.json_rpc_post(payload=payload)
98+
value = self.json_rpc_post(payload=payload, non_rpc_response=non_rpc_response)
9399
if value is not None:
94100
self.cache.store_key_value(cache_key, value)
95101
return value

src/registries.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def get_collector_registry(self) -> list:
9090
collector = collectors.AptosCollector
9191
case "tron", "tron":
9292
collector = collectors.TronCollector
93+
case "xrpl", "xrpl":
94+
collector = collectors.XRPLCollector
9395
case "evmhttp", other: # pylint: disable=unused-variable
9496
collector = collectors.EvmHttpCollector
9597
case "evm", other: # pylint: disable=unused-variable

src/test_collectors.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,3 +811,104 @@ def test_latency(self):
811811
"""Tests that the latency is obtained from the interface based on latest_query_latency"""
812812
self.mocked_connection.return_value.latest_query_latency = 0.123
813813
self.assertEqual(0.123, self.tron_collector.latency())
814+
815+
class TestXRPLCollector(TestCase):
816+
"""Tests the XRPL collector class"""
817+
818+
def setUp(self):
819+
self.url = "https://test.com"
820+
self.labels = ["dummy", "labels"]
821+
self.chain_id = 123
822+
self.open_timeout = 8
823+
self.ping_timeout = 9
824+
self.client_params = {
825+
"open_timeout": self.open_timeout, "ping_timeout": self.ping_timeout}
826+
with mock.patch('collectors.HttpsInterface') as mocked_connection:
827+
self.xrpl_collector = collectors.XRPLCollector(
828+
self.url, self.labels, self.chain_id, **self.client_params)
829+
self.mocked_connection = mocked_connection
830+
831+
def test_logger_metadata(self):
832+
"""Validate logger metadata. Makes sure url is stripped by helpers.strip_url function."""
833+
expected_metadata = {
834+
'component': 'XRPLCollector', 'url': 'test.com'}
835+
self.assertEqual(expected_metadata,
836+
self.xrpl_collector._logger_metadata)
837+
838+
def test_https_interface_created(self):
839+
"""Tests that the XRPL collector calls the https interface with the correct args"""
840+
self.mocked_connection.assert_called_once_with(
841+
self.url, self.open_timeout, self.ping_timeout)
842+
843+
def test_interface_attribute_exists(self):
844+
"""Tests that the interface attribute exists."""
845+
self.assertTrue(hasattr(self.xrpl_collector, 'interface'))
846+
847+
def test_alive_call(self):
848+
"""Tests the alive function uses the correct call"""
849+
self.xrpl_collector.alive()
850+
self.mocked_connection.return_value.cached_json_rpc_post.assert_called_once_with(
851+
self.xrpl_collector.ledger_closed_payload)
852+
853+
def test_alive_false(self):
854+
"""Tests the alive function returns false when post returns None"""
855+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = None
856+
result = self.xrpl_collector.alive()
857+
self.assertFalse(result)
858+
859+
def test_block_height(self):
860+
"""Tests the block_height function uses the correct call to get block height"""
861+
self.xrpl_collector.block_height()
862+
self.mocked_connection.return_value.cached_json_rpc_post.assert_called_once_with(
863+
self.xrpl_collector.ledger_closed_payload)
864+
865+
def test_block_height_get_ledger_index(self):
866+
"""Tests that the block height is returned with the ledger_index key"""
867+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = {
868+
"ledger_index": 96217031}
869+
result = self.xrpl_collector.block_height()
870+
self.assertEqual(96217031, result)
871+
872+
def test_block_height_key_error_returns_none(self):
873+
"""Tests that the block height returns None on KeyError"""
874+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = {
875+
"dummy_key": 5}
876+
result = self.xrpl_collector.block_height()
877+
self.assertEqual(None, result)
878+
879+
def test_block_height_returns_none(self):
880+
"""Tests that the block height returns None if json_rpc_post returns None"""
881+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = None
882+
result = self.xrpl_collector.block_height()
883+
self.assertEqual(None, result)
884+
885+
def test_client_version(self):
886+
"""Tests the client_version function uses the correct call to get client version"""
887+
self.xrpl_collector.client_version()
888+
self.mocked_connection.return_value.cached_json_rpc_post.assert_called_once_with(
889+
self.xrpl_collector.server_info_payload)
890+
891+
def test_client_version_get_build_version(self):
892+
"""Tests that the client version is returned with the build_version key"""
893+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = {
894+
"info": {"build_version": "2.4.0"}}
895+
result = self.xrpl_collector.client_version()
896+
self.assertEqual({"client_version": "2.4.0"}, result)
897+
898+
def test_client_version_key_error_returns_none(self):
899+
"""Tests that the client_version returns None on KeyError"""
900+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = {
901+
"info": {"dummy_key": "value"}}
902+
result = self.xrpl_collector.client_version()
903+
self.assertEqual(None, result)
904+
905+
def test_client_version_returns_none(self):
906+
"""Tests that the client_version returns None if json_rpc_post returns None"""
907+
self.mocked_connection.return_value.cached_json_rpc_post.return_value = None
908+
result = self.xrpl_collector.client_version()
909+
self.assertEqual(None, result)
910+
911+
def test_latency(self):
912+
"""Tests that the latency is obtained from the interface based on latest_query_latency"""
913+
self.mocked_connection.return_value.latest_query_latency = 0.123
914+
self.assertEqual(0.123, self.xrpl_collector.latency())

src/test_registries.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ def test_get_collector_registry_for_tron(self):
157157
with mock.patch('collectors.TronCollector', new=mock.Mock()) as collector:
158158
helper_test_collector_registry(self, collector)
159159

160+
@mock.patch.dict(os.environ, {
161+
"CONFIG_FILE_PATH": "tests/fixtures/configuration_xrpl.yaml",
162+
"VALIDATION_FILE_PATH": "tests/fixtures/validation.yaml"
163+
})
164+
def test_get_collector_registry_for_xrpl(self):
165+
"""Tests that the XRPL collector is called with the correct args"""
166+
self.collector_registry = CollectorRegistry()
167+
with mock.patch('collectors.XRPLCollector', new=mock.Mock()) as collector:
168+
helper_test_collector_registry(self, collector)
169+
160170
@mock.patch.dict(os.environ, {
161171
"CONFIG_FILE_PATH": "tests/fixtures/configuration_evmhttp.yaml",
162172
"VALIDATION_FILE_PATH": "tests/fixtures/validation.yaml"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
blockchain: "xrpl"
2+
chain_id: 1234
3+
network_name: "Testnet"
4+
network_type: "Testnet"
5+
integration_maturity: "development"
6+
canonical_name: "test-network-testnet"
7+
chain_selector: 121212
8+
collector: "xrpl"
9+
endpoints:
10+
- url: https://test1.com
11+
provider: TestProvider1
12+
- url: https://test2.com
13+
provider: TestProvider2
14+
- url: https://test3.com
15+
provider: TestProvider3

0 commit comments

Comments
 (0)