add storage to stats collector
This commit is contained in:
Generated
+1
-15
@@ -6822,41 +6822,27 @@ name = "nym-statistics-collector"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
"bs58",
|
||||
"bytes",
|
||||
"clap",
|
||||
"defguard_wireguard_rs",
|
||||
"fastrand 2.1.1",
|
||||
"futures",
|
||||
"ipnetwork 0.20.0",
|
||||
"log",
|
||||
"nym-bin-common",
|
||||
"nym-client-core",
|
||||
"nym-config",
|
||||
"nym-credential-verification",
|
||||
"nym-credentials-interface",
|
||||
"nym-crypto",
|
||||
"nym-gateway-requests",
|
||||
"nym-gateway-storage",
|
||||
"nym-id",
|
||||
"nym-network-defaults",
|
||||
"nym-sdk",
|
||||
"nym-service-provider-requests-common",
|
||||
"nym-service-providers-common",
|
||||
"nym-sphinx",
|
||||
"nym-statistics-common",
|
||||
"nym-task",
|
||||
"nym-types",
|
||||
"nym-wireguard",
|
||||
"nym-wireguard-types",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"url",
|
||||
]
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ impl StatisticsControl {
|
||||
log::error!("Failed to report client stats: {:?}", err);
|
||||
} else {
|
||||
self.stats.reset();
|
||||
log::debug!("Stats report successfully sent");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ use nym_credentials_interface::TicketType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ConnectionStats {
|
||||
pub struct ConnectionStats {
|
||||
//tickets
|
||||
mixnet_entry_spent: u32,
|
||||
vpn_entry_spent: u32,
|
||||
mixnet_exit_spent: u32,
|
||||
vpn_exit_spent: u32,
|
||||
pub mixnet_entry_spent: u32,
|
||||
pub vpn_entry_spent: u32,
|
||||
pub mixnet_exit_spent: u32,
|
||||
pub vpn_exit_spent: u32,
|
||||
|
||||
//country_connection
|
||||
wg_exit_country_code: String,
|
||||
mix_exit_country_code: String,
|
||||
pub wg_exit_country_code: String,
|
||||
pub mix_exit_country_code: String,
|
||||
}
|
||||
|
||||
/// Event space for Nym API statistics tracking
|
||||
|
||||
@@ -12,7 +12,7 @@ use nym_metrics::{inc, inc_by};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct GatewayStats {
|
||||
pub struct GatewayStats {
|
||||
// Sent
|
||||
real_packets_sent: u64,
|
||||
real_packets_sent_size: usize,
|
||||
|
||||
@@ -12,7 +12,7 @@ use nym_metrics::{inc, inc_by};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct NymApiStats {
|
||||
pub struct NymApiStats {
|
||||
// Sent
|
||||
real_packets_sent: u64,
|
||||
real_packets_sent_size: usize,
|
||||
|
||||
@@ -61,7 +61,7 @@ impl Instant {
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PacketStatistics {
|
||||
pub struct PacketStatistics {
|
||||
// Sent
|
||||
real_packets_sent: u64,
|
||||
real_packets_sent_size: usize,
|
||||
|
||||
@@ -18,16 +18,16 @@ const VERSION: &str = "v1";
|
||||
/// Report object containing both data to be reported and client / device context. We take extra care not to overcapture context information.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ClientStatsReport {
|
||||
pub(crate) kind: String,
|
||||
pub(crate) api_version: String,
|
||||
pub(crate) last_update_time: OffsetDateTime,
|
||||
pub(crate) client_id: String,
|
||||
pub(crate) client_type: String,
|
||||
pub(crate) os_information: OsInformation,
|
||||
pub(crate) packet_stats: PacketStatistics,
|
||||
pub(crate) gateway_conn_stats: GatewayStats,
|
||||
pub(crate) nym_api_stats: NymApiStats,
|
||||
pub(crate) connection_stats: ConnectionStats,
|
||||
pub kind: String,
|
||||
pub api_version: String,
|
||||
pub last_update_time: OffsetDateTime,
|
||||
pub client_id: String,
|
||||
pub client_type: String,
|
||||
pub os_information: OsInformation,
|
||||
pub packet_stats: PacketStatistics,
|
||||
pub gateway_conn_stats: GatewayStats,
|
||||
pub nym_api_stats: NymApiStats,
|
||||
pub connection_stats: ConnectionStats,
|
||||
}
|
||||
|
||||
impl From<ClientStatsReport> for Vec<u8> {
|
||||
@@ -65,9 +65,9 @@ impl Default for ClientStatsReport {
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct OsInformation {
|
||||
pub(crate) os_type: String,
|
||||
pub(crate) os_version: Option<String>,
|
||||
pub(crate) os_arch: String,
|
||||
pub os_type: String,
|
||||
pub os_version: Option<String>,
|
||||
pub os_arch: String,
|
||||
}
|
||||
|
||||
impl OsInformation {
|
||||
|
||||
@@ -10,43 +10,44 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
bs58 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
clap = { workspace = true, features = ["cargo", "derive"] }
|
||||
defguard_wireguard_rs = { workspace = true }
|
||||
fastrand = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
ipnetwork = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true, features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
"time",
|
||||
] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "net"] }
|
||||
tokio-stream = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["codec"] }
|
||||
url = { workspace = true }
|
||||
|
||||
nym-bin-common = { path = "../../common/bin-common", features = [
|
||||
"clap",
|
||||
"output_format",
|
||||
] }
|
||||
|
||||
nym-client-core = { path = "../../common/client-core", features = ["cli"] }
|
||||
nym-config = { path = "../../common/config" }
|
||||
nym-credentials-interface = { path = "../../common/credentials-interface" }
|
||||
nym-credential-verification = { path = "../../common/credential-verification" }
|
||||
nym-crypto = { path = "../../common/crypto" }
|
||||
nym-gateway-requests = { path = "../../common/gateway-requests" }
|
||||
nym-gateway-storage = { path = "../../common/gateway-storage" }
|
||||
nym-id = { path = "../../common/nym-id" }
|
||||
nym-network-defaults = { path = "../../common/network-defaults" }
|
||||
nym-sdk = { path = "../../sdk/rust/nym-sdk" }
|
||||
nym-service-providers-common = { path = "../common" }
|
||||
nym-service-provider-requests-common = { path = "../../common/service-provider-requests-common" }
|
||||
nym-statistics-common = { path = "../../common/statistics" }
|
||||
nym-sphinx = { path = "../../common/nymsphinx" }
|
||||
nym-task = { path = "../../common/task" }
|
||||
nym-types = { path = "../../common/types" }
|
||||
nym-wireguard = { path = "../../common/wireguard" }
|
||||
nym-wireguard-types = { path = "../../common/wireguard-types" }
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
tokio = { workspace = true, features = ["macros"] }
|
||||
sqlx = { workspace = true, features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
"macros",
|
||||
"migrate",
|
||||
] }
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use sqlx::{Connection, SqliteConnection};
|
||||
use std::env;
|
||||
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let database_path = format!("{out_dir}/stats-collector-example.sqlite");
|
||||
|
||||
let mut conn = SqliteConnection::connect(&format!("sqlite://{database_path}?mode=rwc"))
|
||||
.await
|
||||
.expect("Failed to create SQLx database connection");
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&mut conn)
|
||||
.await
|
||||
.expect("Failed to perform SQLx migrations");
|
||||
|
||||
#[cfg(target_family = "unix")]
|
||||
println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path);
|
||||
|
||||
#[cfg(target_family = "windows")]
|
||||
// for some strange reason we need to add a leading `/` to the windows path even though it's
|
||||
// not a valid windows path... but hey, it works...
|
||||
println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE report (
|
||||
day DATE NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
client_type TEXT,
|
||||
os_type TEXT,
|
||||
os_version TEXT,
|
||||
architecture TEXT,
|
||||
PRIMARY KEY (client_id, day)
|
||||
);
|
||||
|
||||
CREATE TABLE connection_stats (
|
||||
received_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
mixnet_entry_spent INTEGER,
|
||||
vpn_entry_spent INTEGER,
|
||||
mixnet_exit_spent INTEGER,
|
||||
vpn_exit_spent INTEGER,
|
||||
wg_exit_country_code TEXT,
|
||||
mix_exit_country_code TEXT,
|
||||
PRIMARY KEY (client_id, received_at)
|
||||
);
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
|
||||
use nym_client_core::config::disk_persistence::CommonClientPaths;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_REPORT_DATABASE_PATH: &str = "report.db";
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq, Serialize, Clone)]
|
||||
pub struct StatsCollectorPaths {
|
||||
#[serde(flatten)]
|
||||
pub common_paths: CommonClientPaths,
|
||||
pub client_reports_database: PathBuf,
|
||||
}
|
||||
|
||||
impl StatsCollectorPaths {
|
||||
@@ -17,6 +20,7 @@ impl StatsCollectorPaths {
|
||||
|
||||
Self {
|
||||
common_paths: CommonClientPaths::new_base(base_dir),
|
||||
client_reports_database: base_dir.join(DEFAULT_REPORT_DATABASE_PATH),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ reply_surb_database = '{{ storage_paths.reply_surb_database }}'
|
||||
# i.e. details such as their public keys, owner addresses or the network information.
|
||||
gateway_registrations = '{{ storage_paths.gateway_registrations }}'
|
||||
|
||||
# Path to the client reports database
|
||||
client_reports_database = '{{ storage_paths.client_reports_database }}'
|
||||
|
||||
|
||||
##### logging configuration options #####
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ pub enum StatsCollectorError {
|
||||
|
||||
#[error(transparent)]
|
||||
NymIdError(#[from] NymIdError),
|
||||
|
||||
#[error("Storage error : {0}")]
|
||||
ReportStorageError(#[from] crate::storage::error::ClientStatsReportStorageError),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, StatsCollectorError>;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//pub use authenticator::{Authenticator, OnStartData};
|
||||
//pub use config::Config;
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod mixnet_client;
|
||||
pub mod mixnet_listener;
|
||||
pub mod stats_collector;
|
||||
pub mod storage;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub async fn create_mixnet_client(
|
||||
.disable_main_poisson_packet_distribution = true;
|
||||
debug_config.cover_traffic.disable_loop_cover_traffic_stream = true;
|
||||
debug_config.stats_reporting.enabled = false;
|
||||
debug_config.topology.ignore_egress_epoch_role = true; //necessary for a fixed address
|
||||
|
||||
let storage_paths = nym_sdk::mixnet::StoragePaths::from(paths.clone());
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::error::*;
|
||||
use crate::{error::*, storage::ClientStatsStorage};
|
||||
use futures::StreamExt;
|
||||
use nym_sphinx::receiver::ReconstructedMessage;
|
||||
use nym_statistics_common::report::ClientStatsReport;
|
||||
@@ -11,14 +11,22 @@ pub(crate) struct MixnetListener {
|
||||
// The mixnet client that we use to send and receive packets from the mixnet
|
||||
pub(crate) mixnet_client: nym_sdk::mixnet::MixnetClient,
|
||||
|
||||
// Report storage
|
||||
pub(crate) client_report_storage: ClientStatsStorage,
|
||||
|
||||
// The task handle for the main loop
|
||||
pub(crate) task_handle: TaskHandle,
|
||||
}
|
||||
|
||||
impl MixnetListener {
|
||||
pub fn new(mixnet_client: nym_sdk::mixnet::MixnetClient, task_handle: TaskHandle) -> Self {
|
||||
pub fn new(
|
||||
mixnet_client: nym_sdk::mixnet::MixnetClient,
|
||||
client_report_storage: ClientStatsStorage,
|
||||
task_handle: TaskHandle,
|
||||
) -> Self {
|
||||
MixnetListener {
|
||||
mixnet_client,
|
||||
client_report_storage,
|
||||
task_handle,
|
||||
}
|
||||
}
|
||||
@@ -27,12 +35,15 @@ impl MixnetListener {
|
||||
&mut self,
|
||||
reconstructed: ReconstructedMessage,
|
||||
) -> Result<()> {
|
||||
println!("Received message");
|
||||
log::debug!(
|
||||
"Received message with sender_tag: {:?}",
|
||||
reconstructed.sender_tag
|
||||
);
|
||||
let report = deserialize_stats_report(&reconstructed)?;
|
||||
println!("report : {report:?}");
|
||||
println!("Received report");
|
||||
self.client_report_storage.store_report(report).await?;
|
||||
println!("Stored report");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use nym_client_core::{HardcodedTopologyProvider, TopologyProvider};
|
||||
use nym_sdk::{mixnet::Recipient, GatewayTransceiver};
|
||||
use nym_task::{TaskClient, TaskHandle};
|
||||
|
||||
use crate::{config::Config, error::StatsCollectorError};
|
||||
use crate::{config::Config, error::StatsCollectorError, storage::ClientStatsStorage};
|
||||
|
||||
pub struct OnStartData {
|
||||
// to add more fields as required
|
||||
@@ -102,8 +102,11 @@ impl StatisticsCollector {
|
||||
|
||||
let self_address = *mixnet_client.nym_address();
|
||||
|
||||
let report_storage =
|
||||
ClientStatsStorage::init(self.config.storage_paths.client_reports_database).await?;
|
||||
|
||||
let mixnet_listener =
|
||||
crate::mixnet_listener::MixnetListener::new(mixnet_client, task_handle);
|
||||
crate::mixnet_listener::MixnetListener::new(mixnet_client, report_storage, task_handle);
|
||||
|
||||
log::info!("The address of this client is: {self_address}");
|
||||
log::info!("All systems go. Press CTRL-C to stop the server.");
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use nym_statistics_common::report::ClientStatsReport;
|
||||
|
||||
pub(crate) type Result<T> = std::result::Result<T, sqlx::Error>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ClientStatsReportManager {
|
||||
connection_pool: sqlx::SqlitePool,
|
||||
}
|
||||
|
||||
impl ClientStatsReportManager {
|
||||
/// Creates new instance of the `ClientStatsReportManager` with the provided sqlite connection pool.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `connection_pool`: database connection pool to use.
|
||||
pub(crate) fn new(connection_pool: sqlx::SqlitePool) -> Self {
|
||||
ClientStatsReportManager { connection_pool }
|
||||
}
|
||||
|
||||
pub async fn store_report(&mut self, report: ClientStatsReport) -> Result<()> {
|
||||
self.store_base(&report).await?;
|
||||
self.store_connection_stats(&report).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn store_base(&self, report: &ClientStatsReport) -> Result<()> {
|
||||
let report_day = report.last_update_time.date();
|
||||
sqlx::query!(
|
||||
"INSERT OR IGNORE INTO report (day, client_id, client_type, os_type, os_version, architecture) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
report_day,
|
||||
report.client_id,
|
||||
report.client_type,
|
||||
report.os_information.os_type,
|
||||
report.os_information.os_version,
|
||||
report.os_information.os_arch
|
||||
)
|
||||
.execute(&self.connection_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn store_connection_stats(&self, report: &ClientStatsReport) -> Result<()> {
|
||||
sqlx::query!(
|
||||
"INSERT OR IGNORE INTO connection_stats (
|
||||
received_at,
|
||||
client_id,
|
||||
mixnet_entry_spent,
|
||||
vpn_entry_spent,
|
||||
mixnet_exit_spent,
|
||||
vpn_exit_spent,
|
||||
wg_exit_country_code,
|
||||
mix_exit_country_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
report.last_update_time,
|
||||
report.client_id,
|
||||
report.connection_stats.mixnet_entry_spent,
|
||||
report.connection_stats.vpn_entry_spent,
|
||||
report.connection_stats.mixnet_exit_spent,
|
||||
report.connection_stats.vpn_exit_spent,
|
||||
report.connection_stats.wg_exit_country_code,
|
||||
report.connection_stats.mix_exit_country_code
|
||||
)
|
||||
.execute(&self.connection_pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ClientStatsReportStorageError {
|
||||
#[error("Database experienced an internal error: {0}")]
|
||||
InternalDatabaseError(#[from] sqlx::Error),
|
||||
|
||||
#[error("Failed to perform database migration: {0}")]
|
||||
MigrationError(#[from] sqlx::migrate::MigrateError),
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use error::ClientStatsReportStorageError;
|
||||
use log::{debug, error};
|
||||
use nym_statistics_common::report::ClientStatsReport;
|
||||
use sqlx::{
|
||||
sqlite::{SqliteAutoVacuum, SqliteSynchronous},
|
||||
ConnectOptions,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
pub mod error;
|
||||
//pub mod models;
|
||||
mod client_stats_report;
|
||||
|
||||
// note that clone here is fine as upon cloning the same underlying pool will be used
|
||||
#[derive(Clone)]
|
||||
pub struct ClientStatsStorage {
|
||||
client_stats_report_manager: client_stats_report::ClientStatsReportManager,
|
||||
}
|
||||
|
||||
impl ClientStatsStorage {
|
||||
/// Initialises `ClientStatsStorage` using the provided path.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `database_path`: path to the database.
|
||||
pub async fn init<P: AsRef<Path> + Send>(
|
||||
database_path: P,
|
||||
) -> Result<Self, ClientStatsReportStorageError> {
|
||||
debug!(
|
||||
"Attempting to connect to database {:?}",
|
||||
database_path.as_ref().as_os_str()
|
||||
);
|
||||
|
||||
// TODO: we can inject here more stuff based on our gateway global config
|
||||
// struct. Maybe different pool size or timeout intervals?
|
||||
let opts = sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.auto_vacuum(SqliteAutoVacuum::Incremental)
|
||||
.filename(database_path)
|
||||
.create_if_missing(true)
|
||||
.disable_statement_logging();
|
||||
|
||||
// TODO: do we want auto_vacuum ?
|
||||
|
||||
let connection_pool = match sqlx::SqlitePool::connect_with(opts).await {
|
||||
Ok(db) => db,
|
||||
Err(err) => {
|
||||
error!("Failed to connect to SQLx database: {err}");
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = sqlx::migrate!("./migrations").run(&connection_pool).await {
|
||||
error!("Failed to perform migration on the SQLx database: {err}");
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
// the cloning here are cheap as connection pool is stored behind an Arc
|
||||
Ok(ClientStatsStorage {
|
||||
client_stats_report_manager: client_stats_report::ClientStatsReportManager::new(
|
||||
connection_pool,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn store_report(
|
||||
&mut self,
|
||||
report: ClientStatsReport,
|
||||
) -> Result<(), ClientStatsReportStorageError> {
|
||||
Ok(self
|
||||
.client_stats_report_manager
|
||||
.store_report(report)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user