Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions e2e-tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ max_client_to_self_delay = 1024
min_payment_size_msat = 0
max_payment_size_msat = 1000000000
client_trusts_lsp = true

[metrics]
enabled = true
"#,
storage_dir = storage_dir.display(),
);
Expand Down
16 changes: 16 additions & 0 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,3 +611,19 @@ async fn test_forwarded_payment_event() {

node_c.stop().unwrap();
}

#[tokio::test]
async fn test_metrics_endpoint() {
let bitcoind = TestBitcoind::new();

// Test with metrics enabled
let server = LdkServerHandle::start(&bitcoind).await;
let client = server.client();
let metrics_result = client.get_metrics().await;

assert!(metrics_result.is_ok(), "Expected metrics to succeed when enabled");
let metrics = metrics_result.unwrap();

assert!(metrics.contains("# HELP ldk_server_total_peers_count Total number of peers"));
assert!(metrics.contains("# TYPE ldk_server_total_peers_count gauge"));
}
79 changes: 56 additions & 23 deletions ldk-server-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ use ldk_server_protos::api::{
use ldk_server_protos::endpoints::{
BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH,
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH,
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH,
ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
SPONTANEOUS_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH,
GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH,
GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH,
OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH,
UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
};
use ldk_server_protos::error::{ErrorCode, ErrorResponse};
use prost::bytes::Bytes;
use prost::Message;
use reqwest::header::CONTENT_TYPE;
use reqwest::{Certificate, Client};
Expand All @@ -61,6 +63,11 @@ pub struct LdkServerClient {
api_key: String,
}

enum RequestType {
Get,
Post,
}

impl LdkServerClient {
/// Constructs a [`LdkServerClient`] using `base_url` as the ldk-server endpoint.
///
Expand Down Expand Up @@ -106,6 +113,18 @@ impl LdkServerClient {
self.post_request(&request, &url).await
}

/// Retrieve the node metrics in Prometheus format.
pub async fn get_metrics(&self) -> Result<String, LdkServerError> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a raw string, really should be decoded into the Response type

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

really should be decoded into the Response type

The Response type is protobuf, but Promotheus scrapers needs the endpoint to return plain-text

let url = format!("https://{}/{GET_METRICS_PATH}", self.base_url);
let payload = self.make_request(&url, RequestType::Get, None, false).await?;
String::from_utf8(payload.to_vec()).map_err(|e| {
LdkServerError::new(
InternalError,
format!("Failed to decode metrics response as string: {}", e),
)
})
}

/// Retrieves an overview of all known balances.
/// For API contract/usage, refer to docs for [`GetBalancesRequest`] and [`GetBalancesResponse`].
pub async fn get_balances(
Expand Down Expand Up @@ -353,31 +372,45 @@ impl LdkServerClient {
&self, request: &Rq, url: &str,
) -> Result<Rs, LdkServerError> {
let request_body = request.encode_to_vec();
let auth_header = self.compute_auth_header(&request_body);
let response_raw = self
.client
.post(url)
.header(CONTENT_TYPE, APPLICATION_OCTET_STREAM)
.header("X-Auth", auth_header)
.body(request_body)
.send()
.await
.map_err(|e| {
LdkServerError::new(InternalError, format!("HTTP request failed: {}", e))
})?;
let payload = self.make_request(url, RequestType::Post, Some(request_body), true).await?;
Rs::decode(&payload[..]).map_err(|e| {
LdkServerError::new(InternalError, format!("Failed to decode success response: {}", e))
})
}

async fn make_request(
&self, url: &str, request_type: RequestType, body: Option<Vec<u8>>, authenticated: bool,
) -> Result<Bytes, LdkServerError> {
let builder = match request_type {
RequestType::Get => self.client.get(url),
RequestType::Post => self.client.post(url),
};

let builder = if authenticated {
let body_for_auth = body.as_deref().unwrap_or(&[]);
let auth_header = self.compute_auth_header(body_for_auth);
builder.header("X-Auth", auth_header)
} else {
builder
};

let builder = if let Some(body_content) = body {
builder.header(CONTENT_TYPE, APPLICATION_OCTET_STREAM).body(body_content)
} else {
builder
};

let response_raw = builder.send().await.map_err(|e| {
LdkServerError::new(InternalError, format!("HTTP request failed: {}", e))
})?;

let status = response_raw.status();
let payload = response_raw.bytes().await.map_err(|e| {
LdkServerError::new(InternalError, format!("Failed to read response body: {}", e))
})?;

if status.is_success() {
Ok(Rs::decode(&payload[..]).map_err(|e| {
LdkServerError::new(
InternalError,
format!("Failed to decode success response: {}", e),
)
})?)
Ok(payload)
} else {
let error_response = ErrorResponse::decode(&payload[..]).map_err(|e| {
LdkServerError::new(
Expand Down
1 change: 1 addition & 0 deletions ldk-server-protos/src/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ pub const GRAPH_LIST_CHANNELS_PATH: &str = "GraphListChannels";
pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel";
pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes";
pub const GRAPH_GET_NODE_PATH: &str = "GraphGetNode";
pub const GET_METRICS_PATH: &str = "metrics";
4 changes: 4 additions & 0 deletions ldk-server/ldk-server-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,7 @@ client_trusts_lsp = false
## A token we may require to be sent by the clients.
## If set, only requests matching this token will be accepted. (uncomment and set if required)
# require_token = ""

# Metrics settings
[metrics]
enabled = false
32 changes: 31 additions & 1 deletion ldk-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ use crate::io::persist::{
use crate::service::NodeService;
use crate::util::config::{load_config, ArgsConfig, ChainSource};
use crate::util::logger::ServerLogger;
use crate::util::metrics::{Metrics, BUILD_METRICS_INTERVAL};
use crate::util::proto_adapter::{forwarded_payment_to_proto, payment_to_proto};
use crate::util::tls::get_or_generate_tls_config;

Expand Down Expand Up @@ -256,6 +257,27 @@ fn main() {
}
};
let event_node = Arc::clone(&node);

let metrics: Option<Arc<Metrics>> = if config_file.metrics_enabled {
let metrics_node = Arc::clone(&node);
let mut interval = tokio::time::interval(BUILD_METRICS_INTERVAL);
let metrics = Arc::new(Metrics::new());
let metrics_bg = Arc::clone(&metrics);

// Initialize metrics that are event-driven to ensure they start with correct values from persistence
metrics.initialize_payment_metrics(&metrics_node);

runtime.spawn(async move {
loop {
interval.tick().await;
metrics_bg.update_all_pollable_metrics(&metrics_node);
}
});
Some(metrics)
} else {
None
};

let rest_svc_listener = TcpListener::bind(config_file.rest_service_addr)
.await
.expect("Failed to bind listening port");
Expand Down Expand Up @@ -320,6 +342,10 @@ fn main() {
&event_node,
Arc::clone(&event_publisher),
Arc::clone(&paginated_store)).await;

if let Some(metrics) = &metrics {
metrics.update_payments_count(true);
}
},
Event::PaymentFailed {payment_id, ..} => {
let payment_id = payment_id.expect("PaymentId expected for ldk-server >=0.1");
Expand All @@ -331,6 +357,10 @@ fn main() {
&event_node,
Arc::clone(&event_publisher),
Arc::clone(&paginated_store)).await;

if let Some(metrics) = &metrics {
metrics.update_payments_count(false);
}
},
Event::PaymentClaimable {payment_id, ..} => {
if let Some(payment_details) = event_node.payment(&payment_id) {
Expand Down Expand Up @@ -415,7 +445,7 @@ fn main() {
res = rest_svc_listener.accept() => {
match res {
Ok((stream, _)) => {
let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), api_key.clone());
let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), api_key.clone(), metrics.clone());
let acceptor = tls_acceptor.clone();
runtime.spawn(async move {
match acceptor.accept(stream).await {
Expand Down
39 changes: 33 additions & 6 deletions ldk-server/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ use ldk_node::Node;
use ldk_server_protos::endpoints::{
BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH,
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH,
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH,
ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
SPONTANEOUS_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH,
GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH,
GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH,
OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH,
UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
};
use prost::Message;

Expand Down Expand Up @@ -58,6 +59,7 @@ use crate::api::spontaneous_send::handle_spontaneous_send_request;
use crate::api::update_channel_config::handle_update_channel_config_request;
use crate::api::verify_signature::handle_verify_signature_request;
use crate::io::persist::paginated_kv_store::PaginatedKVStore;
use crate::util::metrics::Metrics;
use crate::util::proto_adapter::to_error_response;

// Maximum request body size: 10 MB
Expand All @@ -69,13 +71,15 @@ pub struct NodeService {
node: Arc<Node>,
paginated_kv_store: Arc<dyn PaginatedKVStore>,
api_key: String,
metrics: Option<Arc<Metrics>>,
}

impl NodeService {
pub(crate) fn new(
node: Arc<Node>, paginated_kv_store: Arc<dyn PaginatedKVStore>, api_key: String,
metrics: Option<Arc<Metrics>>,
) -> Self {
Self { node, paginated_kv_store, api_key }
Self { node, paginated_kv_store, api_key, metrics }
}
}

Expand Down Expand Up @@ -159,6 +163,29 @@ impl Service<Request<Incoming>> for NodeService {
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

fn call(&self, req: Request<Incoming>) -> Self::Future {
// Handle metrics endpoint separately to bypass auth and return plain text
if req.method() == hyper::Method::GET
&& req.uri().path().len() > 1
&& &req.uri().path()[1..] == GET_METRICS_PATH
{
if let Some(metrics) = &self.metrics {
let metrics = Arc::clone(metrics);
return Box::pin(async move {
Ok(Response::builder()
.header("Content-Type", "text/plain")
.body(Full::new(Bytes::from(metrics.gather_metrics())))
.unwrap())
});
} else {
return Box::pin(async move {
Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Full::new(Bytes::from("Not Found")))
.unwrap())
});
}
}

// Extract auth params from headers (validation happens after body is read)
let auth_params = match extract_auth_params(&req) {
Ok(params) => params,
Expand Down
Loading