diff --git a/Cargo.lock b/Cargo.lock index b0e02891..cb17b89c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -40,9 +40,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.95" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" [[package]] name = "arbitrary" @@ -455,6 +455,32 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "canlog" +version = "0.1.0" +dependencies = [ + "candid", + "canlog_derive", + "ic-canister-log", + "ic-cdk", + "proptest", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "canlog_derive" +version = "0.1.0" +dependencies = [ + "canlog", + "darling", + "proc-macro2", + "quote", + "serde", + "syn 2.0.98", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -479,9 +505,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.14" +version = "1.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" +checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" dependencies = [ "shlex", ] @@ -703,6 +729,41 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.98", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.98", +] + [[package]] name = "data-encoding" version = "2.8.0" @@ -839,9 +900,9 @@ dependencies = [ [[package]] name = "either" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d" [[package]] name = "ena" @@ -1259,6 +1320,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "ic-canister-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb82c4f617ecff6e452fe65af0489626ec7330ffe3eedd9ea14e6178eea48d1a" +dependencies = [ + "serde", +] + [[package]] name = "ic-cdk" version = "0.17.1" @@ -1486,6 +1556,12 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -1602,9 +1678,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" [[package]] name = "libc" -version = "0.2.169" +version = "0.2.170" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" [[package]] name = "libredox" @@ -1670,9 +1746,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "litemap" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "lock_api" @@ -1686,9 +1762,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" [[package]] name = "logos" @@ -1785,9 +1861,9 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", ] @@ -2318,9 +2394,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ "bitflags", ] @@ -2432,9 +2508,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.9" +version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24" +checksum = "da5349ae27d3887ca812fb375b45a4fbb36d8d12d2df394968cd86e35683fe73" dependencies = [ "cc", "cfg-if", @@ -2577,9 +2653,9 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "schemars_derive", @@ -2589,9 +2665,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", @@ -2863,6 +2939,7 @@ version = "0.1.0" dependencies = [ "candid", "candid_parser", + "canlog", "ciborium", "const_format", "hex", @@ -2871,7 +2948,7 @@ dependencies = [ "proptest", "regex", "serde", - "serde_json", + "serde_bytes", "sol_rpc_types", "url", "zeroize", @@ -2894,10 +2971,14 @@ version = "0.1.0" dependencies = [ "async-trait", "candid", + "canlog", "ic-cdk", "ic-test-utilities-load-wasm", "pocket-ic", "serde", + "serde_bytes", + "serde_json", + "sol_rpc_canister", "sol_rpc_client", "sol_rpc_types", "tokio", @@ -2908,6 +2989,7 @@ name = "sol_rpc_types" version = "0.1.0" dependencies = [ "candid", + "canlog", "ic-cdk", "regex", "serde", @@ -3718,9 +3800,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stacker" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d08feb8f695b465baed819b03c128dc23f57a694510ab1f06c77f763975685e" +checksum = "d9156ebd5870ef293bfb43f91c7a74528d363ec0d424afe24160ed5a4343d08a" dependencies = [ "cc", "cfg-if", @@ -3741,6 +3823,12 @@ dependencies = [ "precomputed-hash", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.26.3" @@ -4587,9 +4675,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" +checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" dependencies = [ "memchr", ] @@ -4668,18 +4756,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 965fe399..31c4e567 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,11 @@ resolver = "2" members = [ "canister", "integration_tests", + "canlog", + "canlog_derive", "libs/client", "libs/types", - "examples/basic_solana" -] + "examples/basic_solana"] [workspace.package] authors = ["DFINITY Stiftung"] @@ -35,6 +36,7 @@ ciborium = "0.2.2" const_format = "0.2.34" getrandom = { version = "*", default-features = false, features = ["custom"] } hex = "0.4.3" +ic-canister-log = "0.2.0" ic-cdk = "0.17.1" ic-ed25519 = "0.1.0" ic-stable-structures = "0.6.7" @@ -43,6 +45,7 @@ pocket-ic = "6.0.0" proptest = "1.6.0" regex = "1.11.1" serde = { version = "1.0.218", features = ["derive"] } +serde_bytes = "0.11.15" serde_json = "1.0.139" solana-hash = "2.2.0" solana-instruction = "2.2.0" @@ -53,7 +56,6 @@ solana-pubkey = "2.2.0" solana-signature = "2.2.0" solana-transaction = { version = "2.2.0", features = ["bincode"] } strum = { version = "0.27.0", features = ["derive"] } -thiserror = "2.0.11" tokio = "1.43.0" url = "2.5" zeroize = { version = "1.8", features = ["zeroize_derive"] } diff --git a/canister/Cargo.toml b/canister/Cargo.toml index 0dc760e0..cf87b1c7 100644 --- a/canister/Cargo.toml +++ b/canister/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] candid = { workspace = true } +canlog = { path = "../canlog", features = ["derive"] } ciborium = { workspace = true } const_format = { workspace = true } hex = { workspace = true } @@ -22,7 +23,7 @@ ic-stable-structures = { workspace = true } sol_rpc_types = { path = "../libs/types" } regex = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +serde_bytes = { workspace = true } url = { workspace = true } zeroize = { workspace = true } diff --git a/canister/sol_rpc_canister.did b/canister/sol_rpc_canister.did index b8e03c01..17753d2d 100644 --- a/canister/sol_rpc_canister.did +++ b/canister/sol_rpc_canister.did @@ -54,9 +54,16 @@ type RegexSubstitution = record { type OverrideProvider = record { overrideUrl : opt RegexSubstitution }; +type LogFilter = variant { + ShowAll; + HideAll; + ShowPattern : Regex; + HidePattern : Regex; +}; type InstallArgs = record { manageApiKeys: opt vec principal; overrideProvider: opt OverrideProvider; + logFilter: opt LogFilter; }; service : (InstallArgs,) -> { getProviders : () -> (vec Provider) query; diff --git a/canister/src/http_types/mod.rs b/canister/src/http_types/mod.rs new file mode 100644 index 00000000..0b4247e3 --- /dev/null +++ b/canister/src/http_types/mod.rs @@ -0,0 +1,106 @@ +//! Copy of the types from the unpublished [`ic-canisters-http-types`](https://github.com/dfinity/ic/blob/f4242cbcf83f0725663f3cd1a6b3a83eb2dace01/rs/rust_canisters/http_types/src/lib.rs) crate. + +#[cfg(test)] +mod tests; + +use candid::{CandidType, Deserialize}; +use serde_bytes::ByteBuf; + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct HttpRequest { + pub method: String, + pub url: String, + pub headers: Vec<(String, String)>, + pub body: ByteBuf, +} + +impl HttpRequest { + pub fn path(&self) -> &str { + match self.url.find('?') { + None => &self.url[..], + Some(index) => &self.url[..index], + } + } + + /// Searches for the first appearance of a parameter in the request URL. + /// Returns `None` if the given parameter does not appear in the query. + pub fn raw_query_param(&self, param: &str) -> Option<&str> { + const QUERY_SEPARATOR: &str = "?"; + let query_string = self.url.split(QUERY_SEPARATOR).nth(1)?; + if query_string.is_empty() { + return None; + } + const PARAMETER_SEPARATOR: &str = "&"; + for chunk in query_string.split(PARAMETER_SEPARATOR) { + const KEY_VALUE_SEPARATOR: &str = "="; + let mut split = chunk.splitn(2, KEY_VALUE_SEPARATOR); + let name = split.next()?; + if name == param { + return Some(split.next().unwrap_or_default()); + } + } + None + } +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct HttpResponse { + pub status_code: u16, + pub headers: Vec<(String, String)>, + pub body: ByteBuf, +} + +pub struct HttpResponseBuilder(HttpResponse); + +impl HttpResponseBuilder { + pub fn ok() -> Self { + Self(HttpResponse { + status_code: 200, + headers: vec![], + body: ByteBuf::default(), + }) + } + + pub fn bad_request() -> Self { + Self(HttpResponse { + status_code: 400, + headers: vec![], + body: ByteBuf::from("bad request"), + }) + } + + pub fn not_found() -> Self { + Self(HttpResponse { + status_code: 404, + headers: vec![], + body: ByteBuf::from("not found"), + }) + } + + pub fn server_error(reason: impl ToString) -> Self { + Self(HttpResponse { + status_code: 500, + headers: vec![], + body: ByteBuf::from(reason.to_string()), + }) + } + + pub fn header(mut self, name: impl ToString, value: impl ToString) -> Self { + self.0.headers.push((name.to_string(), value.to_string())); + self + } + + pub fn body(mut self, bytes: impl Into>) -> Self { + self.0.body = ByteBuf::from(bytes.into()); + self + } + + pub fn with_body_and_content_length(self, bytes: impl Into>) -> Self { + let bytes = bytes.into(); + self.header("Content-Length", bytes.len()).body(bytes) + } + + pub fn build(self) -> HttpResponse { + self.0 + } +} diff --git a/canister/src/http_types/tests.rs b/canister/src/http_types/tests.rs new file mode 100644 index 00000000..eaac5677 --- /dev/null +++ b/canister/src/http_types/tests.rs @@ -0,0 +1,20 @@ +use crate::http_types::HttpRequest; + +#[test] +fn test_raw_query_param() { + fn request_with_url(url: String) -> HttpRequest { + HttpRequest { + method: "".to_string(), + url, + headers: vec![], + body: Default::default(), + } + } + let http_request = request_with_url("/endpoint?time=1000".to_string()); + assert_eq!(http_request.raw_query_param("time"), Some("1000")); + let http_request = request_with_url("/endpoint".to_string()); + assert_eq!(http_request.raw_query_param("time"), None); + let http_request = + request_with_url("/endpoint?time=1000&time=1001&other=abcde&time=1002".to_string()); + assert_eq!(http_request.raw_query_param("time"), Some("1000")); +} diff --git a/canister/src/lib.rs b/canister/src/lib.rs index b1611b30..cf5a74a6 100644 --- a/canister/src/lib.rs +++ b/canister/src/lib.rs @@ -1,5 +1,7 @@ pub mod constants; +pub mod http_types; pub mod lifecycle; +pub mod logs; pub mod providers; pub mod rpc_client; pub mod state; diff --git a/canister/src/lifecycle/mod.rs b/canister/src/lifecycle/mod.rs index 4617f509..39e5441c 100644 --- a/canister/src/lifecycle/mod.rs +++ b/canister/src/lifecycle/mod.rs @@ -1,29 +1,29 @@ -use crate::state::{init_state, mutate_state, State}; +use crate::{ + logs::Priority, + state::{init_state, mutate_state, State}, +}; +use canlog::log; use sol_rpc_types::InstallArgs; pub fn init(args: InstallArgs) { - // TODO XC-286: Add logging - // log!( - // INFO, - // "[init]: initialized SOL RPC canister with arg: {:?}", - // args - // ); init_state(State::from(args)); } pub fn post_upgrade(args: Option) { if let Some(args) = args { - // TODO XC-286: Add logging - // log!( - // INFO, - // "[init]: upgraded SOL RPC canister with arg: {:?}", - // args - // ); + log!( + Priority::Info, + "[init]: upgraded SOL RPC canister with arg: {:?}", + args + ); if let Some(api_key_principals) = args.manage_api_keys { mutate_state(|s| s.set_api_key_principals(api_key_principals)); } if let Some(override_provider) = args.override_provider { mutate_state(|s| s.set_override_provider(override_provider.into())); } + if let Some(log_filter) = args.log_filter { + mutate_state(|s| s.set_log_filter(log_filter)); + } } } diff --git a/canister/src/logs/mod.rs b/canister/src/logs/mod.rs new file mode 100644 index 00000000..4a608a7f --- /dev/null +++ b/canister/src/logs/mod.rs @@ -0,0 +1,33 @@ +use crate::state::read_state; +use canlog::{GetLogFilter, LogFilter, LogPriorityLevels}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(LogPriorityLevels, Serialize, Deserialize, PartialEq, Debug, Copy, Clone)] +pub enum Priority { + #[log_level(capacity = 1000, name = "INFO")] + Info, + #[log_level(capacity = 1000, name = "DEBUG")] + Debug, + #[log_level(capacity = 1000, name = "TRACE_HTTP")] + TraceHttp, +} + +impl GetLogFilter for Priority { + fn get_log_filter() -> LogFilter { + read_state(|state| state.get_log_filter()) + } +} + +impl FromStr for Priority { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "info" => Ok(Priority::Info), + "trace_http" => Ok(Priority::TraceHttp), + "debug" => Ok(Priority::Debug), + _ => Err("could not recognize priority".to_string()), + } + } +} diff --git a/canister/src/main.rs b/canister/src/main.rs index 5cdc49eb..5f80aea9 100644 --- a/canister/src/main.rs +++ b/canister/src/main.rs @@ -1,12 +1,17 @@ use candid::candid_method; -use ic_cdk::api::is_controller; -use ic_cdk::{query, update}; +use canlog::{log, Log, Sort}; +use ic_cdk::{ + api::is_controller, + {query, update}, +}; use sol_rpc_canister::{ - lifecycle, + http_types, lifecycle, + logs::Priority, providers::{find_provider, PROVIDERS}, state::{mutate_state, read_state}, }; use sol_rpc_types::{ProviderId, RpcAccess}; +use std::str::FromStr; pub fn require_api_key_principal_or_controller() -> Result<(), String> { let caller = ic_cdk::caller(); @@ -35,17 +40,16 @@ fn get_providers() -> Vec { /// /// Panics if the list of provider IDs includes a nonexistent or "unauthenticated" (fully public) provider. async fn update_api_keys(api_keys: Vec<(ProviderId, Option)>) { - // TODO XC-286: Add logs - // log!( - // INFO, - // "[{}] Updating API keys for providers: {}", - // ic_cdk::caller(), - // api_keys - // .iter() - // .map(|(id, _)| id.to_string()) - // .collect::>() - // .join(", ") - // ); + log!( + Priority::Info, + "[{}] Updating API keys for providers: {}", + ic_cdk::caller(), + api_keys + .iter() + .map(|(id, _)| id.to_string()) + .collect::>() + .join(", ") + ); for (provider_id, api_key) in api_keys { let provider = find_provider(|provider| provider.provider_id == provider_id) .unwrap_or_else(|| panic!("Provider not found: {}", provider_id)); @@ -64,6 +68,67 @@ async fn update_api_keys(api_keys: Vec<(ProviderId, Option)>) { } } +#[query(hidden = true)] +fn http_request(request: http_types::HttpRequest) -> http_types::HttpResponse { + match request.path() { + "/logs" => { + let max_skip_timestamp = match request.raw_query_param("time") { + Some(arg) => match u64::from_str(arg) { + Ok(value) => value, + Err(_) => { + return http_types::HttpResponseBuilder::bad_request() + .with_body_and_content_length("failed to parse the 'time' parameter") + .build() + } + }, + None => 0, + }; + + let mut log: Log = Default::default(); + + match request.raw_query_param("priority").map(Priority::from_str) { + Some(Ok(priority)) => match priority { + Priority::Info => log.push_logs(Priority::Info), + Priority::Debug => log.push_logs(Priority::Debug), + Priority::TraceHttp => {} + }, + Some(Err(_)) | None => { + log.push_logs(Priority::Info); + log.push_logs(Priority::Debug); + } + } + + log.entries + .retain(|entry| entry.timestamp >= max_skip_timestamp); + + fn ordering_from_query_params(sort: Option<&str>, max_skip_timestamp: u64) -> Sort { + match sort.map(Sort::from_str) { + Some(Ok(order)) => order, + Some(Err(_)) | None => { + if max_skip_timestamp == 0 { + Sort::Ascending + } else { + Sort::Descending + } + } + } + } + + log.sort_logs(ordering_from_query_params( + request.raw_query_param("sort"), + max_skip_timestamp, + )); + + const MAX_BODY_SIZE: usize = 2_000_000; + http_types::HttpResponseBuilder::ok() + .header("Content-Type", "application/json; charset=utf-8") + .with_body_and_content_length(log.serialize_logs(MAX_BODY_SIZE)) + .build() + } + _ => http_types::HttpResponseBuilder::not_found().build(), + } +} + #[query( guard = "require_api_key_principal_or_controller", name = "verifyApiKey", diff --git a/canister/src/state/mod.rs b/canister/src/state/mod.rs index c7d43a36..7dc24526 100644 --- a/canister/src/state/mod.rs +++ b/canister/src/state/mod.rs @@ -3,6 +3,7 @@ mod tests; use crate::types::{ApiKey, OverrideProvider}; use candid::{Deserialize, Principal}; +use canlog::LogFilter; use ic_stable_structures::{ memory_manager::{MemoryId, MemoryManager, VirtualMemory}, storable::Bound, @@ -84,6 +85,7 @@ pub struct State { api_keys: BTreeMap, api_key_principals: Vec, override_provider: OverrideProvider, + log_filter: LogFilter, } impl State { @@ -121,6 +123,14 @@ impl State { pub fn set_override_provider(&mut self, override_provider: OverrideProvider) { self.override_provider = override_provider } + + pub fn get_log_filter(&self) -> LogFilter { + self.log_filter.clone() + } + + pub fn set_log_filter(&mut self, filter: LogFilter) { + self.log_filter = filter; + } } impl From for State { @@ -129,6 +139,7 @@ impl From for State { api_keys: Default::default(), api_key_principals: value.manage_api_keys.unwrap_or_default(), override_provider: value.override_provider.unwrap_or_default().into(), + log_filter: value.log_filter.unwrap_or_default(), } } } diff --git a/canlog/CHANGELOG.md b/canlog/CHANGELOG.md new file mode 100644 index 00000000..5fda63a8 --- /dev/null +++ b/canlog/CHANGELOG.md @@ -0,0 +1,8 @@ +Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] \ No newline at end of file diff --git a/canlog/Cargo.toml b/canlog/Cargo.toml new file mode 100644 index 00000000..ff0e982c --- /dev/null +++ b/canlog/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "canlog" +version = "0.1.0" +description = "Crate for managing canister logs" +authors.workspace = true +edition.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +readme = "README.md" +include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] + +[dependencies] +candid = { workspace = true } +canlog_derive = { path = "../canlog_derive", optional = true } +ic-canister-log = { workspace = true } +ic-cdk = { workspace = true } +regex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +canlog_derive = { path = "../canlog_derive" } + +[features] +derive = ["dep:canlog_derive"] diff --git a/canlog/LICENSE b/canlog/LICENSE new file mode 120000 index 00000000..ea5b6064 --- /dev/null +++ b/canlog/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/canlog/README.md b/canlog/README.md new file mode 100644 index 00000000..601ac611 --- /dev/null +++ b/canlog/README.md @@ -0,0 +1,2 @@ +# Crate `canlog` + diff --git a/canlog/src/lib.rs b/canlog/src/lib.rs new file mode 100644 index 00000000..7ea1b3ac --- /dev/null +++ b/canlog/src/lib.rs @@ -0,0 +1,234 @@ +//! This crate extends [`ic_canister_log`] to provide native support for log priority levels, +//! filtering and sorting. +//! +//! The main functionality is provided by the [`LogPriorityLevels`] and [`GetLogFilter`] traits +//! as well as the [`log`] macro. +//! +//! Custom log priority levels may be defined by declaring an enum and implementing the +//! [`LogPriorityLevels`] trait for it, usually through the [`derive`] annotation available with +//! the `derive` feature of [`canlog`]. +//! +//! Additionally, log filtering may be achieved by implementing the [`GetLogFilter`] trait on +//! the enum defining the log priorities. +//! +//! * Example: +//! ```rust +//! # #[cfg(feature="derive")] +//! # mod wrapper_module { +//! use canlog::{GetLogFilter, LogFilter, LogPriorityLevels, log}; +//! +//! #[derive(LogPriorityLevels)] +//! enum LogPriority { +//! #[log_level(capacity = 100, name = "INFO")] +//! Info, +//! #[log_level(capacity = 500, name = "DEBUG")] +//! Debug, +//! } +//! +//! impl GetLogFilter for LogPriority { +//! fn get_log_filter() -> LogFilter { +//! LogFilter::ShowAll +//! } +//! } +//! +//! fn main() { +//! log!(LogPriority::Info, "Some rather important message."); +//! log!(LogPriority::Debug, "Some less important message."); +//! } +//! # } +//! ``` +//! +//! **Expected Output:** +//! ```text +//! 2025-02-26 08:27:10 UTC: [Canister lxzze-o7777-77777-aaaaa-cai] INFO main.rs:13 Some rather important message. +//! 2025-02-26 08:27:10 UTC: [Canister lxzze-o7777-77777-aaaaa-cai] DEBUG main.rs:14 Some less important message. +//! ``` + +#![forbid(unsafe_code)] +#![forbid(missing_docs)] + +#[cfg(test)] +mod tests; +mod types; + +extern crate self as canlog; + +pub use crate::types::{LogFilter, Sort}; + +pub use ic_canister_log::{ + declare_log_buffer, export as export_logs, log as raw_log, GlobalBuffer, Sink, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(any(feature = "derive", test))] +/// A procedural macro to implement [`LogPriorityLevels`] for an enum. +/// +/// This macro expects the variants to be annotated with `#[log_level(capacity = N, name = "NAME")]` +/// where `N` is an integer representing buffer capacity and `"NAME"` is a string display +/// representation for the corresponding log level. +/// +/// The enum annotated with `#[derive(LogPriorityLevels)]` must also implement the +/// [`Serialize`], [`Deserialize`], [`Clone`] and [`Copy`] traits. +/// +/// See the top-level crate documentation for example usage. +#[doc(inline)] +pub use canlog_derive::LogPriorityLevels; + +/// Wrapper for the [`ic_canister_log::log`](ic_canister_log::log!) macro that allows +/// logging for a given variant of an enum implementing the [`LogPriorityLevels`] +/// trait. See the example in the crate documentation. +#[macro_export] +macro_rules! log { + ($enum_variant:expr, $($args:tt)*) => { + { + use ::canlog::LogPriorityLevels; + ::canlog::raw_log!($enum_variant.get_sink(), $($args)*); + } + }; +} + +/// Represents a log priority level. This trait is meant to be implemented +/// automatically with the [`derive`](macro@derive) attribute macro which +/// is available with the `derive` feature of this crate. +pub trait LogPriorityLevels { + #[doc(hidden)] + fn get_buffer(&self) -> &'static GlobalBuffer; + #[doc(hidden)] + fn get_sink(&self) -> &impl Sink; + + /// Returns a display representation for a log priority level. + fn display_name(&self) -> &'static str; + + /// Returns an array containing all the log priority levels. + fn get_priorities() -> &'static [Self] + where + Self: Sized; +} + +/// Returns the [`LogFilter`] to check what entries to record. This trait should +/// be implemented manually. +pub trait GetLogFilter { + /// Returns a [`LogFilter`]. Only log entries matching this filter will be recorded. + fn get_log_filter() -> LogFilter; +} + +/// A single log entry. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, serde::Serialize)] +pub struct LogEntry { + /// The time at which the log entry is recorded. + pub timestamp: u64, + /// The log entry priority level. + pub priority: Priority, + /// The source file in which this log entry was generated. + pub file: String, + /// The line in [`file`] in which this log entry was generated. + pub line: u32, + /// The log message. + pub message: String, + /// The index of this entry starting from the last canister upgrade. + pub counter: u64, +} + +/// A container for log entries at a given log priority level. +#[derive(Clone, Debug, Deserialize, serde::Serialize)] +pub struct Log { + /// The log entries for this priority level. + pub entries: Vec>, +} + +impl Default for Log { + fn default() -> Self { + Self { entries: vec![] } + } +} + +impl<'de, Priority> Log +where + Priority: LogPriorityLevels + Clone + Copy + Deserialize<'de> + Serialize + 'static, +{ + /// Append all the entries from the given `Priority` to [`Log::entries`]. + pub fn push_logs(&mut self, priority: Priority) { + for entry in export_logs(priority.get_buffer()) { + self.entries.push(LogEntry { + timestamp: entry.timestamp, + counter: entry.counter, + priority, + file: entry.file.to_string(), + line: entry.line, + message: entry.message, + }); + } + } + + /// Append all the entries from all priority levels to [`Log::entries`]. + pub fn push_all(&mut self) { + Priority::get_priorities() + .iter() + .for_each(|priority| self.push_logs(*priority)); + } + + /// Serialize the logs contained in `entries` into a JSON string. + /// + /// If the resulting string is larger than `max_body_size` bytes, + /// truncate `entries` so the resulting serialized JSON string + /// contains no more than `max_body_size` bytes. + pub fn serialize_logs(&self, max_body_size: usize) -> String { + let mut entries_json: String = serde_json::to_string(&self).unwrap_or_default(); + + if entries_json.len() > max_body_size { + let mut left = 0; + let mut right = self.entries.len(); + + while left < right { + let mid = left + (right - left) / 2; + let mut temp_log = self.clone(); + temp_log.entries.truncate(mid); + let temp_entries_json = serde_json::to_string(&temp_log).unwrap_or_default(); + + if temp_entries_json.len() <= max_body_size { + entries_json = temp_entries_json; + left = mid + 1; + } else { + right = mid; + } + } + } + entries_json + } + + /// Sort the log entries according `sort_order`. + pub fn sort_logs(&mut self, sort_order: Sort) { + match sort_order { + Sort::Ascending => self.sort_asc(), + Sort::Descending => self.sort_desc(), + } + } + + fn sort_asc(&mut self) { + self.entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + } + + fn sort_desc(&mut self) { + self.entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + } +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct PrintProxySink(pub &'static Priority, pub &'static GlobalBuffer); + +impl Sink for PrintProxySink { + fn append(&self, entry: ic_canister_log::LogEntry) { + let message = format!( + "{} {}:{} {}", + self.0.display_name(), + entry.file, + entry.line, + entry.message, + ); + if Priority::get_log_filter().is_match(&message) { + ic_cdk::println!("{}", message); + self.1.append(entry) + } + } +} diff --git a/canlog/src/tests.rs b/canlog/src/tests.rs new file mode 100644 index 00000000..c31f20c7 --- /dev/null +++ b/canlog/src/tests.rs @@ -0,0 +1,226 @@ +use crate::{log, GetLogFilter, Log, LogEntry, LogFilter, LogPriorityLevels, Sort}; +use proptest::{prop_assert, proptest}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; + +thread_local! { + static LOG_FILTER: RefCell = RefCell::default(); +} + +#[derive(Clone, Copy, Serialize, Deserialize, LogPriorityLevels)] +enum TestPriority { + #[log_level(capacity = 1000, name = "INFO_TEST")] + Info, +} + +impl GetLogFilter for TestPriority { + fn get_log_filter() -> LogFilter { + LOG_FILTER.with(|cell| cell.borrow().clone()) + } +} + +fn set_log_filter(filter: LogFilter) { + LOG_FILTER.set(filter); +} + +fn info_log_entry_with_timestamp(timestamp: u64) -> LogEntry { + LogEntry { + timestamp, + priority: TestPriority::Info, + file: String::default(), + line: 0, + message: String::default(), + counter: 0, + } +} + +fn is_ascending(log: &Log) -> bool { + for i in 0..log.entries.len() - 1 { + if log.entries[i].timestamp > log.entries[i + 1].timestamp { + return false; + } + } + true +} + +fn is_descending(log: &Log) -> bool { + for i in 0..log.entries.len() - 1 { + if log.entries[i].timestamp < log.entries[i + 1].timestamp { + return false; + } + } + true +} + +fn get_messages() -> Vec { + canlog::export_logs(TestPriority::Info.get_buffer()) + .into_iter() + .map(|entry| entry.message) + .collect() +} + +proptest! { + #[test] + fn logs_always_fit_in_message( + number_of_entries in 1..100_usize, + entry_size in 1..10000_usize, + max_body_size in 100..10000_usize + ) { + let mut entries: Vec> = vec![]; + for _ in 0..number_of_entries { + entries.push(LogEntry { + timestamp: 0, + priority: TestPriority::Info, + file: String::default(), + line: 0, + message: "1".repeat(entry_size), + counter: 0, + }); + } + let log = Log { entries }; + let truncated_logs_json_len = log.serialize_logs(max_body_size).len(); + prop_assert!(truncated_logs_json_len <= max_body_size); + } +} + +#[test] +fn sorting_order() { + let mut log = Log { entries: vec![] }; + log.entries.push(info_log_entry_with_timestamp(2)); + log.entries.push(info_log_entry_with_timestamp(0)); + log.entries.push(info_log_entry_with_timestamp(1)); + + log.sort_logs(Sort::Ascending); + assert!(is_ascending(&log)); + + log.sort_logs(Sort::Descending); + assert!(is_descending(&log)); +} + +#[test] +fn simple_logs_truncation() { + let mut entries: Vec> = vec![]; + const MAX_BODY_SIZE: usize = 3_000_000; + + for _ in 0..10 { + entries.push(LogEntry { + timestamp: 0, + priority: TestPriority::Info, + file: String::default(), + line: 0, + message: String::default(), + counter: 0, + }); + } + let log = Log { + entries: entries.clone(), + }; + let small_len = serde_json::to_string(&log).unwrap_or_default().len(); + + entries.push(LogEntry { + timestamp: 0, + priority: TestPriority::Info, + file: String::default(), + line: 0, + message: "1".repeat(MAX_BODY_SIZE), + counter: 0, + }); + let log = Log { entries }; + let entries_json = serde_json::to_string(&log).unwrap_or_default(); + assert!(entries_json.len() > MAX_BODY_SIZE); + + let truncated_logs_json = log.serialize_logs(MAX_BODY_SIZE); + + assert_eq!(small_len, truncated_logs_json.len()); +} + +#[test] +fn one_entry_too_big() { + let mut entries: Vec> = vec![]; + const MAX_BODY_SIZE: usize = 3_000_000; + + entries.push(LogEntry { + timestamp: 0, + priority: TestPriority::Info, + file: String::default(), + line: 0, + message: "1".repeat(MAX_BODY_SIZE), + counter: 0, + }); + let log = Log { entries }; + let truncated_logs_json_len = log.serialize_logs(MAX_BODY_SIZE).len(); + assert!(truncated_logs_json_len < MAX_BODY_SIZE); + assert_eq!("{\"entries\":[]}", log.serialize_logs(MAX_BODY_SIZE)); +} + +#[test] +fn should_truncate_last_entry() { + let log_entries = vec![ + info_log_entry_with_timestamp(0), + info_log_entry_with_timestamp(1), + info_log_entry_with_timestamp(2), + ]; + let log_with_2_entries = Log { + entries: { + let mut entries = log_entries.clone(); + entries.pop(); + entries + }, + }; + let log_with_3_entries = Log { + entries: log_entries, + }; + + let serialized_log_with_2_entries = log_with_2_entries.serialize_logs(usize::MAX); + let serialized_log_with_3_entries = + log_with_3_entries.serialize_logs(serialized_log_with_2_entries.len()); + + assert_eq!(serialized_log_with_3_entries, serialized_log_with_2_entries); +} + +#[test] +fn should_show_all() { + set_log_filter(LogFilter::ShowAll); + log!(TestPriority::Info, "ABC"); + log!(TestPriority::Info, "123"); + log!(TestPriority::Info, "!@#"); + assert_eq!(get_messages(), vec!["ABC", "123", "!@#"]); +} + +#[test] +fn should_hide_all() { + set_log_filter(LogFilter::HideAll); + log!(TestPriority::Info, "ABC"); + log!(TestPriority::Info, "123"); + log!(TestPriority::Info, "!@#"); + assert_eq!(get_messages().len(), 0); +} + +#[test] +fn should_show_pattern() { + set_log_filter(LogFilter::ShowPattern("end$".into())); + log!(TestPriority::Info, "message"); + log!(TestPriority::Info, "message end"); + log!(TestPriority::Info, "end message"); + assert_eq!(get_messages(), vec!["message end"]); +} + +#[test] +fn should_hide_pattern_including_message_type() { + set_log_filter(LogFilter::ShowPattern("^INFO_TEST [^ ]* 123".into())); + log!(TestPriority::Info, "123"); + log!(TestPriority::Info, "INFO_TEST 123"); + log!(TestPriority::Info, ""); + log!(TestPriority::Info, "123456"); + assert_eq!(get_messages(), vec!["123", "123456"]); +} + +#[test] +fn should_hide_pattern() { + set_log_filter(LogFilter::HidePattern("[ABC]".into())); + log!(TestPriority::Info, "remove A"); + log!(TestPriority::Info, "...B..."); + log!(TestPriority::Info, "C"); + log!(TestPriority::Info, "message"); + assert_eq!(get_messages(), vec!["message"]); +} diff --git a/canlog/src/types/mod.rs b/canlog/src/types/mod.rs new file mode 100644 index 00000000..256c0dae --- /dev/null +++ b/canlog/src/types/mod.rs @@ -0,0 +1,92 @@ +use candid::CandidType; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +/// A string used as a regex pattern. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Serialize, Deserialize)] +pub struct RegexString(pub String); + +impl From<&str> for RegexString { + fn from(value: &str) -> Self { + RegexString(value.to_string()) + } +} + +impl RegexString { + /// Compile the string into a regular expression. + /// + /// This is a relatively expensive operation that's currently not cached. + pub fn compile(&self) -> Result { + Regex::new(&self.0) + } + + /// Checks if the given string matches the compiled regex pattern. + /// + /// Returns `Ok(true)` if `value` matches, `Ok(false)` if not, or an error if the regex is invalid. + pub fn try_is_valid(&self, value: &str) -> Result { + Ok(self.compile()?.is_match(value)) + } +} + +/// A regex-based substitution with a pattern and replacement string. +#[derive(Clone, Debug, PartialEq, Eq, CandidType, Serialize, Deserialize)] +pub struct RegexSubstitution { + /// The pattern to be matched. + pub pattern: RegexString, + /// The string to replace occurrences [`pattern`] with. + pub replacement: String, +} + +/// Only log entries matching this filter will be recorded. +#[derive(Clone, Debug, Default, PartialEq, Eq, CandidType, Serialize, Deserialize)] +pub enum LogFilter { + /// All log entries are recorded. + #[default] + ShowAll, + /// No log entries are recorded. + HideAll, + /// Only log entries matching this regular expression are recorded. + ShowPattern(RegexString), + /// Only log entries not matching this regular expression are recorded. + HidePattern(RegexString), +} + +impl LogFilter { + /// Returns whether the given message matches the [`LogFilter`]. + pub fn is_match(&self, message: &str) -> bool { + match self { + Self::ShowAll => true, + Self::HideAll => false, + Self::ShowPattern(regex) => regex + .try_is_valid(message) + .expect("Invalid regex in ShowPattern log filter"), + Self::HidePattern(regex) => !regex + .try_is_valid(message) + .expect("Invalid regex in HidePattern log filter"), + } + } +} + +/// Defines a sorting order for log entries +#[derive(Copy, Clone, Debug, Deserialize, serde::Serialize)] +pub enum Sort { + /// Log entries are sorted in ascending chronological order, i.e. + /// from oldest to newest. + Ascending, + /// Log entries are sorted in descending chronological order, i.e. + /// from newest to oldest. + Descending, +} + +impl FromStr for Sort { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "asc" => Ok(Sort::Ascending), + "desc" => Ok(Sort::Descending), + _ => Err("could not recognize sort order".to_string()), + } + } +} diff --git a/canlog_derive/CHANGELOG.md b/canlog_derive/CHANGELOG.md new file mode 100644 index 00000000..5fda63a8 --- /dev/null +++ b/canlog_derive/CHANGELOG.md @@ -0,0 +1,8 @@ +Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] \ No newline at end of file diff --git a/canlog_derive/Cargo.toml b/canlog_derive/Cargo.toml new file mode 100644 index 00000000..8dd16ae7 --- /dev/null +++ b/canlog_derive/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "canlog_derive" +version = "0.1.0" +description = "Crate with macro definitions for the canlog crate" +authors.workspace = true +edition.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +readme = "README.md" +include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] + +[dependencies] +syn = { version = "2.0.98", features = ["derive"] } +quote = "1.0.38" +proc-macro2 = "1.0.93" +darling = "0.20.10" + +[dev-dependencies] +canlog = { path = "../canlog" } +serde = { workspace = true, features = ["derive"] } + +[lib] +proc-macro = true + diff --git a/canlog_derive/LICENSE b/canlog_derive/LICENSE new file mode 120000 index 00000000..ea5b6064 --- /dev/null +++ b/canlog_derive/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/canlog_derive/README.md b/canlog_derive/README.md new file mode 100644 index 00000000..18aab6f0 --- /dev/null +++ b/canlog_derive/README.md @@ -0,0 +1,2 @@ +# Crate `canlog_derive` + diff --git a/canlog_derive/src/lib.rs b/canlog_derive/src/lib.rs new file mode 100644 index 00000000..7babea6f --- /dev/null +++ b/canlog_derive/src/lib.rs @@ -0,0 +1,108 @@ +//! Procedural macros for the canlog crate. Refer to the canlog crate documentation. + +#![forbid(unsafe_code)] + +use darling::FromVariant; +use proc_macro::TokenStream; +use proc_macro2::Ident; +use quote::quote; +use syn::{parse_macro_input, Data, DataEnum, DeriveInput}; + +#[proc_macro_derive(LogPriorityLevels, attributes(log_level))] +pub fn derive_log_priority(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let enum_ident = &input.ident; + + let Data::Enum(DataEnum { variants, .. }) = &input.data else { + panic!("This trait can only be derived for enums"); + }; + + // Declare a buffer and sink for each enum variant + let buffer_declarations = variants.iter().map(|variant| { + let variant_ident = &variant.ident; + let info = LogLevelInfo::from_variant(variant) + .unwrap_or_else(|_| panic!("Invalid attributes for log level: {}", variant_ident)); + + let buffer_ident = get_buffer_ident(variant_ident); + let sink_ident = get_sink_ident(variant_ident); + let capacity = info.capacity; + + quote! { + ::canlog::declare_log_buffer!(name = #buffer_ident, capacity = #capacity); + pub const #sink_ident: ::canlog::PrintProxySink<#enum_ident> = ::canlog::PrintProxySink(&#enum_ident::#variant_ident, &#buffer_ident); + } + }); + + // Match arms to get the corresponding buffer, sink and display name for each enum variant + let buffer_match_arms = variants.iter().map(|variant| { + let variant_ident = &variant.ident; + let buffer_ident = get_buffer_ident(variant_ident); + quote! { + Self::#variant_ident => &#buffer_ident, + } + }); + let sink_match_arms = variants.iter().map(|variant| { + let variant_ident = &variant.ident; + let sink_ident = get_sink_ident(variant_ident); + quote! { + Self::#variant_ident => &#sink_ident, + } + }); + let display_name_match_arms = variants.iter().map(|variant| { + let variant_ident = &variant.ident; + let display_name = LogLevelInfo::from_variant(variant).unwrap().name; + quote! { + Self::#variant_ident => #display_name, + } + }); + let variants_array = variants.iter().map(|variant| { + let variant_ident = &variant.ident; + quote! { Self::#variant_ident, } + }); + + // Generate buffer declarations and trait implementation + let trait_impl = quote! { + #(#buffer_declarations)* + + impl ::canlog::LogPriorityLevels for #enum_ident { + fn get_buffer(&self) -> &'static ::canlog::GlobalBuffer { + match self { + #(#buffer_match_arms)* + } + } + + fn get_sink(&self) -> &impl ::canlog::Sink { + match self { + #(#sink_match_arms)* + } + } + + fn display_name(&self) -> &'static str { + match self { + #(#display_name_match_arms)* + } + } + + fn get_priorities() -> &'static [Self] { + &[#(#variants_array)*] + } + } + }; + + trait_impl.into() +} + +#[derive(FromVariant)] +#[darling(attributes(log_level))] +struct LogLevelInfo { + capacity: usize, + name: String, +} + +fn get_sink_ident(variant_ident: &Ident) -> Ident { + quote::format_ident!("{}", variant_ident.to_string().to_uppercase()) +} + +fn get_buffer_ident(variant_ident: &Ident) -> Ident { + quote::format_ident!("{}_BUF", variant_ident.to_string().to_uppercase()) +} diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 20cba9f8..0872c083 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -10,10 +10,14 @@ license.workspace = true [dependencies] async-trait = { workspace = true } candid = { workspace = true } +canlog = { path = "../canlog" } ic-cdk = { workspace = true } ic-test-utilities-load-wasm = { workspace = true } pocket-ic = { workspace = true } serde = { workspace = true } +serde_bytes = { workspace = true } +serde_json = { workspace = true } +sol_rpc_canister = { path = "../canister" } sol_rpc_client = { path = "../libs/client" } sol_rpc_types = { path = "../libs/types" } diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 8acb5233..d4240f19 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -1,10 +1,15 @@ use async_trait::async_trait; use candid::utils::ArgumentEncoder; use candid::{decode_args, encode_args, CandidType, Encode, Principal}; +use canlog::{Log, LogEntry}; use ic_cdk::api::call::RejectionCode; use pocket_ic::management_canister::{CanisterId, CanisterSettings}; use pocket_ic::{nonblocking::PocketIc, PocketIcBuilder, UserError, WasmResult}; use serde::de::DeserializeOwned; +use sol_rpc_canister::{ + http_types::{HttpRequest, HttpResponse}, + logs::Priority, +}; use sol_rpc_client::{Runtime, SolRpcClient}; use sol_rpc_types::{InstallArgs, ProviderId}; use std::path::PathBuf; @@ -191,6 +196,7 @@ impl PocketIcRuntime<'_> { #[async_trait] pub trait SolRpcTestClient { async fn verify_api_key(&self, api_key: (ProviderId, Option)); + async fn retrieve_logs(&self, priority: &str) -> Vec>; fn with_caller>(self, id: T) -> Self; } @@ -203,6 +209,23 @@ impl SolRpcTestClient> for SolRpcClient> .unwrap() } + async fn retrieve_logs(&self, priority: &str) -> Vec> { + let request = HttpRequest { + method: "POST".to_string(), + url: format!("/logs?priority={priority}"), + headers: vec![], + body: serde_bytes::ByteBuf::new(), + }; + let response: HttpResponse = self + .runtime + .query_call(self.sol_rpc_canister, "http_request", (request,)) + .await + .unwrap(); + serde_json::from_slice::>(&response.body) + .expect("failed to parse SOL RPC canister log") + .entries + } + fn with_caller>(mut self, id: T) -> Self { self.runtime.caller = id.into(); self diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index f9c204c4..fe45b3d5 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -33,6 +33,33 @@ mod get_provider_tests { } } +mod retrieve_logs_tests { + use super::*; + + #[tokio::test] + async fn should_retrieve_logs() { + let setup = Setup::new().await; + let client = setup.client(); + assert_eq!(client.retrieve_logs("DEBUG").await, vec![]); + assert_eq!(client.retrieve_logs("INFO").await, vec![]); + + // Generate some log + setup + .client() + .with_caller(setup.controller()) + .update_api_keys(&[( + "alchemy-mainnet".to_string(), + Some("unauthorized-api-key".to_string()), + )]) + .await; + + assert_eq!(client.retrieve_logs("DEBUG").await, vec![]); + assert!(client.retrieve_logs("INFO").await[0] + .message + .contains("Updating API keys")); + } +} + mod update_api_key_tests { use super::*; diff --git a/libs/types/Cargo.toml b/libs/types/Cargo.toml index b88786ca..40798c75 100644 --- a/libs/types/Cargo.toml +++ b/libs/types/Cargo.toml @@ -15,5 +15,6 @@ candid = { workspace = true } ic-cdk = { workspace = true } regex = { workspace = true } serde = { workspace = true } +canlog = { path = "../../canlog" } strum = { workspace = true } url = { workspace = true } diff --git a/libs/types/src/lifecycle/mod.rs b/libs/types/src/lifecycle/mod.rs index 3249c0b3..eaa44d78 100644 --- a/libs/types/src/lifecycle/mod.rs +++ b/libs/types/src/lifecycle/mod.rs @@ -1,5 +1,6 @@ use crate::OverrideProvider; use candid::{CandidType, Principal}; +use canlog::LogFilter; use serde::Deserialize; /// The installation args for the Solana RPC canister @@ -11,4 +12,7 @@ pub struct InstallArgs { /// Overrides the RPC providers' default URL and HTTP headers. #[serde(rename = "overrideProvider")] pub override_provider: Option, + /// Only log entries matching this filter will be recorded. + #[serde(rename = "logFilter")] + pub log_filter: Option, } diff --git a/libs/types/src/rpc_client/mod.rs b/libs/types/src/rpc_client/mod.rs index 2f34752f..9a5f6040 100644 --- a/libs/types/src/rpc_client/mod.rs +++ b/libs/types/src/rpc_client/mod.rs @@ -213,7 +213,7 @@ impl RegexString { pub struct RegexSubstitution { /// The pattern to be matched. pub pattern: RegexString, - /// The string to replace occurences [`pattern`](`RegexSubstitution::pattern`) with. + /// The string to replace occurrences [`pattern`](`RegexSubstitution::pattern`) with. pub replacement: String, }