diff --git a/Cargo.lock b/Cargo.lock index 744b2bc..8018fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.10.1" @@ -129,7 +135,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -203,6 +209,30 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -222,10 +252,21 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.2.0", "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "hashbrown" version = "0.15.3" @@ -372,6 +413,17 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.172" @@ -411,6 +463,7 @@ dependencies = [ "serde_json", "time", "ureq", + "uuid", ] [[package]] @@ -480,7 +533,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -522,6 +575,12 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.32" @@ -567,6 +626,12 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ring" version = "0.17.14" @@ -638,6 +703,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.20" @@ -693,7 +764,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -714,6 +785,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.0" @@ -743,6 +820,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -751,7 +839,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -879,6 +967,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -906,6 +1005,51 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "webpki-root-certs" version = "0.26.11" @@ -1059,7 +1203,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "synstructure", ] @@ -1080,7 +1224,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "synstructure", ] @@ -1120,5 +1264,5 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] diff --git a/Cargo.toml b/Cargo.toml index 8213931..bb04560 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ ureq = { version = "3.0.11", default-features = false, features = [ "json", "gzip", ] } +uuid = { version = "1.29.0", features = ["v4"] } [features] default = ["rustls"] diff --git a/README.md b/README.md index 7a5279b..5d32685 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,36 @@ m.set("user".into(), 0.0); m.mutate("user".into(), 1.0); ``` +## Events + +```rust +logdash::create_logdash(logdash::Config::default()); + +// track that a signed-in user ran an agent +logdash::track( + "agent_run", + &[("agent", "support".into()), ("steps", 4.into()), ("success", true.into())], + Some(&user.id), +); +``` + +Events show up in the domain's Analytics as goals, with a daily trend. +Pass the user ID for signed-in users, so one person counts once across the website and apps. +Without it, Logdash counts a visitor of that app that changes every day. +Never put personal data in props. + +## Flush + +Logs, metrics and events are sent in the background. +Call `flush` before your app exits, so nothing queued is lost. + +```rust +// wait until the logs, metrics and events queued so far are sent +logdash::flush(); +``` + +Each request fails after 5 seconds without an answer, so `flush` returns even when the API is unreachable. + ## Apps A domain key is shared by all apps of a domain, for example a desktop app, an API and a landing page. @@ -84,12 +114,12 @@ To see the logs or metrics, go to your project dashboard ## Configuration -| Parameter | Required | Default | Description | -| --------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | -| `api_key` | no | - | Api key used to authorize against logdash servers. If you don't provide one, logs will be logged into local console only | -| `api_url` | no | - | Custom API host, useful with self-hosted instances | -| `verbose` | no | - | Useful for debugging purposes | -| `app` | no | - | Name of the app sending logs and metrics, for example `desktop`. See [Apps](#apps) | +| Parameter | Required | Default | Description | +| --------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `api_key` | no | - | Api key used to authorize against logdash servers. If you don't provide one, logs and events will be logged into local console only | +| `api_url` | no | - | Custom API host, useful with self-hosted instances | +| `verbose` | no | - | Useful for debugging purposes | +| `app` | no | - | Name of the app sending logs, metrics and events, for example `desktop`. See [Apps](#apps) | ## License diff --git a/examples/client_from_config.rs b/examples/client_from_config.rs index a949252..4ca8701 100644 --- a/examples/client_from_config.rs +++ b/examples/client_from_config.rs @@ -12,8 +12,6 @@ fn main() { // Send a metric message m.set("user".into(), 0.0); - // Sleep for 10 seconds to allow the log message to be sent - // This is just for demonstration purposes - // In a real application, you would not want to sleep the thread like this - std::thread::sleep(std::time::Duration::from_secs(10)); + // Wait until the log and the metric are sent before the program exits + logdash::flush(); } diff --git a/examples/client_fron_env.rs b/examples/client_fron_env.rs index 662defb..971aa2b 100644 --- a/examples/client_fron_env.rs +++ b/examples/client_fron_env.rs @@ -14,8 +14,6 @@ fn main() { // Send a metric message m.set("user".into(), 0.0); - // Sleep for 10 seconds to allow the log message to be sent - // This is just for demonstration purposes - // In a real application, you would not want to sleep the thread like this - std::thread::sleep(std::time::Duration::from_secs(10)); + // Wait until the logs and the metric are sent before the program exits + logdash::flush(); } diff --git a/src/dispatch.rs b/src/dispatch.rs index c418ffe..55b68a2 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -1,4 +1,4 @@ -use crate::{log::LogMessage, metric::MetricMessage, propagator::Propagator}; +use crate::{event::EventMessage, log::LogMessage, metric::MetricMessage, propagator::Propagator}; use std::fmt::Debug; use std::sync::{Arc, OnceLock}; @@ -34,4 +34,14 @@ impl Dispatch { pub fn dispatch_metric(&self, msg: MetricMessage) { self.propagator.propagate_metric(msg); } + + #[inline] + pub fn dispatch_event(&self, msg: EventMessage) { + self.propagator.propagate_event(msg); + } + + #[inline] + pub fn flush(&self) { + self.propagator.flush(); + } } diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..e75fe14 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,116 @@ +use crate::dispatch::DISPATCH; +use serde::Serialize; +use serde_json::{Map, Value}; +use uuid::Uuid; + +/// Tracks an event, such as `agent_run`, and sends it in the background like a log. +/// +/// `name` matches `^[a-z][a-z0-9_]{0,63}$`. +/// `pageview`, `pageleave` and `browser_error` belong to the website tracker. +/// `props` holds up to 10 strings, numbers or booleans, never personal data. +/// `user_id` is the signed-in user's internal ID, never an email address. +/// An invalid name, or a user ID that contains `@`, drops the event with a warning. +/// +/// ```no_run +/// logdash::create_logdash(logdash::Config::default().api_key("Your Domain Key".into())); +/// +/// logdash::track( +/// "agent_run", +/// &[("agent", "support".into()), ("steps", 4.into()), ("success", true.into())], +/// Some("42"), +/// ); +/// ``` +pub fn track(name: &str, props: &[(&str, PropValue)], user_id: Option<&str>) { + if !valid_name(name) { + eprintln!( + "Event {name:?} dropped: the name must match ^[a-z][a-z0-9_]{{0,63}}$ and not be pageview, pageleave or browser_error" + ); + return; + } + if user_id.is_some_and(|id| id.contains('@')) { + eprintln!( + "Event {name:?} dropped: the user ID must be an internal ID, never an email address" + ); + return; + } + let Some(dispatch) = DISPATCH.get() else { + eprintln!("Event {name:?} dropped: call logdash::create_logdash first"); + return; + }; + dispatch.dispatch_event(EventMessage { + id: Uuid::new_v4().to_string(), + name: name.into(), + props: props + .iter() + .map(|(key, value)| (key.to_string(), value.0.clone())) + .collect(), + user_id: user_id.map(Into::into), + }); +} + +fn valid_name(name: &str) -> bool { + let bytes = name.as_bytes(); + bytes.first().is_some_and(u8::is_ascii_lowercase) + && bytes.len() <= 64 + && bytes + .iter() + .all(|&c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_') + && !matches!(name, "pageview" | "pageleave" | "browser_error") +} + +/// The value of an event property: a string, a number or a boolean. +#[derive(Debug, Clone)] +pub struct PropValue(Value); + +macro_rules! prop_value_from { + ($($t:ty),*) => {$( + impl From<$t> for PropValue { + fn from(value: $t) -> Self { + Self(value.into()) + } + } + )*}; +} + +prop_value_from!( + &str, String, bool, i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, f32, f64 +); + +/// `id` is a UUID v4, so a retried request stores the event once. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EventMessage { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Map::is_empty")] + pub props: Map, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + +#[cfg(test)] +mod tests { + use super::valid_name; + + #[test] + fn event_names_follow_the_api_rule() { + for name in ["a", "agent_run", "step2_done", "a".repeat(64).as_str()] { + assert!(valid_name(name), "{name:?} is valid"); + } + for name in [ + "", + "Agent", + "agent run", + "agent-run", + "2fa", + "_run", + "zażółć", + "a".repeat(65).as_str(), + "pageview", + "pageleave", + "browser_error", + ] { + assert!(!valid_name(name), "{name:?} is invalid"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index c7d6905..6d39f91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,9 @@ mod dispatch; +mod event; mod log; mod metric; mod propagator; +pub use event::{PropValue, track}; pub use log::LogCollector; pub use metric::MetricCollector; use propagator::Propagator; @@ -18,3 +20,19 @@ pub fn create_logdash(cfg: Config) -> (LogCollector, MetricCollector) { } (LogCollector::new(), MetricCollector::new()) } + +/// Waits until the logs, metrics and events queued so far are sent, for example before the app exits. +/// +/// Each request fails after 5 seconds without an answer, so `flush` returns even when the API is unreachable. +/// +/// ```no_run +/// let (logger, _) = logdash::create_logdash(logdash::Config::default()); +/// +/// logger.info("Desktop app closing"); +/// logdash::flush(); +/// ``` +pub fn flush() { + if let Some(dispatch) = dispatch::DISPATCH.get() { + dispatch.flush(); + } +} diff --git a/src/propagator/http.rs b/src/propagator/http.rs index efa1878..0d093c4 100644 --- a/src/propagator/http.rs +++ b/src/propagator/http.rs @@ -6,11 +6,17 @@ use super::{ }; use serde::Serialize; use std::sync::mpsc::Receiver; +use std::time::Duration; +use ureq::Agent; + +/// A request without an answer by then fails, so a hung connection cannot stall the worker or `flush`. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); + pub struct HttpPropagator { _priv: (), } -/// JSON body of a log or metric, with the app that sent it when one is set. +/// JSON body of a log, metric or event, with the app that sent it when one is set. #[derive(Serialize)] struct Body<'a, T> { #[serde(flatten)] @@ -25,15 +31,21 @@ pub fn http(cfg: PropagatorConfig) -> Worker { impl WorkerJob for HttpPropagator { fn job(cfg: PropagatorConfig, rx: Receiver) { + let agent: Agent = Agent::config_builder() + .timeout_global(Some(REQUEST_TIMEOUT)) + .build() + .into(); let log_url = format!("{}/logs", cfg.api_url); let metric_url = format!("{}/metrics", cfg.api_url); + let event_url = format!("{}/events", cfg.api_url); let api_key = cfg.api_key.unwrap(); let app = cfg.app.as_deref(); loop { match rx.recv() { Ok(msg) => match msg { MessageType::Log(log) => { - if let Err(e) = ureq::post(&log_url) + if let Err(e) = agent + .post(&log_url) .header("project-api-key", &api_key) .send_json(Body { msg: &log, app }) { @@ -45,7 +57,8 @@ impl WorkerJob for HttpPropagator { } } MessageType::Metric(metric) => { - if let Err(e) = ureq::put(&metric_url) + if let Err(e) = agent + .put(&metric_url) .header("project-api-key", &api_key) .send_json(Body { msg: &metric, app }) { @@ -64,6 +77,22 @@ impl WorkerJob for HttpPropagator { } } } + MessageType::Event(event) => { + if let Err(e) = agent + .post(&event_url) + .header("project-api-key", &api_key) + .send_json(Body { msg: &event, app }) + { + eprintln!("Error sending event: {}", e); + continue; + } + if cfg.verbose { + println!("Event sent: {}", event.name); + } + } + MessageType::Flush(flushed) => { + let _ = flushed.send(()); + } }, Err(e) => { eprintln!("Error receiving message: {}", e); diff --git a/src/propagator/mod.rs b/src/propagator/mod.rs index 61848dd..8ce55e6 100644 --- a/src/propagator/mod.rs +++ b/src/propagator/mod.rs @@ -1,9 +1,9 @@ pub mod http; pub mod terminal; mod worker; -use std::sync::Arc; +use std::sync::{Arc, mpsc::Sender}; -use crate::{log::LogMessage, metric::MetricMessage}; +use crate::{event::EventMessage, log::LogMessage, metric::MetricMessage}; pub use http::http; pub use terminal::terminal; #[derive(Debug, Clone)] @@ -40,11 +40,11 @@ impl PropagatorConfig { self } - /// Names the app that sends the logs and metrics, for example `desktop`, `api` or `landing`. + /// Names the app that sends the logs, metrics and events, for example `desktop`, `api` or `landing`. /// /// A domain key is shared by all apps of a domain, and `app` names this one, /// so the dashboard can filter logs and metrics by app. - /// Every log and metric sent with this config carries it. + /// Every log, metric and event sent with this config carries it. /// Older service keys ignore `app`. /// /// ```no_run @@ -70,11 +70,16 @@ impl PropagatorConfig { pub enum MessageType { Log(LogMessage), Metric(MetricMessage), + Event(EventMessage), + /// Answered once every message queued before it is sent. + Flush(Sender<()>), } pub trait Propagator: Send + Sync + 'static { fn propagate_log(&self, msg: LogMessage); fn propagate_metric(&self, msg: MetricMessage); + fn propagate_event(&self, msg: EventMessage); + fn flush(&self); } impl Propagator for Arc { @@ -85,4 +90,12 @@ impl Propagator for Arc { fn propagate_metric(&self, msg: MetricMessage) { self.as_ref().propagate_metric(msg); } + + fn propagate_event(&self, msg: EventMessage) { + self.as_ref().propagate_event(msg); + } + + fn flush(&self) { + self.as_ref().flush(); + } } diff --git a/src/propagator/terminal.rs b/src/propagator/terminal.rs index e840cf4..db80eb0 100644 --- a/src/propagator/terminal.rs +++ b/src/propagator/terminal.rs @@ -3,6 +3,7 @@ use super::{ worker::{Worker, WorkerJob}, }; use owo_colors::OwoColorize; +use serde_json::Value; use std::sync::mpsc::Receiver; pub struct TerminalPropagator { @@ -82,6 +83,12 @@ impl WorkerJob for TerminalPropagator { metric.name, metric.value, metric.operation ); } + MessageType::Event(event) => { + println!("Event: {} {}", event.name, Value::Object(event.props)); + } + MessageType::Flush(flushed) => { + let _ = flushed.send(()); + } }, Err(e) => { eprintln!("Error receiving message: {}", e); diff --git a/src/propagator/worker.rs b/src/propagator/worker.rs index 6140f31..18d3a2c 100644 --- a/src/propagator/worker.rs +++ b/src/propagator/worker.rs @@ -1,7 +1,7 @@ use super::{MessageType, Propagator, PropagatorConfig}; use std::{ marker::PhantomData, - sync::mpsc::{Receiver, SyncSender, sync_channel}, + sync::mpsc::{Receiver, SyncSender, channel, sync_channel}, }; pub trait WorkerJob { fn job(cfg: PropagatorConfig, rx: Receiver); @@ -35,4 +35,17 @@ impl Propagator for Worker { fn propagate_metric(&self, msg: crate::metric::MetricMessage) { let _ = self.sender.send(MessageType::Metric(msg)); } + + fn propagate_event(&self, msg: crate::event::EventMessage) { + let _ = self.sender.send(MessageType::Event(msg)); + } + + // The worker sends messages in order, so its answer means everything queued before is sent. + // A worker that is gone drops the answer channel, which ends the wait too. + fn flush(&self) { + let (flushed, answer) = channel(); + if self.sender.send(MessageType::Flush(flushed)).is_ok() { + let _ = answer.recv(); + } + } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..29f8ed6 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,61 @@ +// Every test binary compiles this module and reads only part of it. +#![allow(dead_code)] + +use serde_json::Value; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc; +use std::thread; + +/// A request the fake API received. +pub struct Request { + pub line: String, + pub headers: Vec, + pub body: Value, +} + +/// Starts a fake API that accepts every request with 202. +/// It hands each request over before it answers, so the SDK never sees an answer to a request the test cannot see yet. +/// Returns its URL and the requests it receives. +pub fn api() -> (String, mpsc::Receiver) { + let server = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", server.local_addr().unwrap()); + let (requests, received) = mpsc::channel(); + thread::spawn(move || { + for stream in server.incoming() { + let stream = stream.unwrap(); + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let mut headers = Vec::new(); + loop { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + match header.trim_end() { + "" => break, + header => headers.push(header.to_ascii_lowercase()), + } + } + let length = headers + .iter() + .find_map(|header| header.strip_prefix("content-length:")) + .map_or(0, |length| length.trim().parse().unwrap()); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + let body = serde_json::from_slice(&body).unwrap(); + requests + .send(Request { + line, + headers, + body, + }) + .unwrap(); + (&stream) + .write_all( + b"HTTP/1.1 202 Accepted\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ) + .unwrap(); + } + }); + (url, received) +} diff --git a/tests/flush.rs b/tests/flush.rs new file mode 100644 index 0000000..36f5666 --- /dev/null +++ b/tests/flush.rs @@ -0,0 +1,31 @@ +mod common; + +#[test] +fn flush_waits_until_queued_logs_metrics_and_events_are_sent() { + // Nothing is queued before create_logdash, so this returns at once. + logdash::flush(); + + let (api_url, requests) = common::api(); + let (logger, metrics) = logdash::create_logdash( + logdash::Config::default() + .api_key("key".into()) + .api_url(api_url), + ); + logger.info("Desktop app closing"); + metrics.set("users".into(), 1.0); + logdash::track("app_closed", &[], None); + + logdash::flush(); + + let sent: Vec<_> = requests + .try_iter() + .map(|request| (request.line, request.body)) + .collect(); + assert_eq!(sent.len(), 3); + assert!(sent[0].0.starts_with("POST /logs ")); + assert_eq!(sent[0].1["message"], "Desktop app closing"); + assert!(sent[1].0.starts_with("PUT /metrics ")); + assert_eq!(sent[1].1["name"], "users"); + assert!(sent[2].0.starts_with("POST /events ")); + assert_eq!(sent[2].1["name"], "app_closed"); +} diff --git a/tests/track.rs b/tests/track.rs new file mode 100644 index 0000000..87b256e --- /dev/null +++ b/tests/track.rs @@ -0,0 +1,69 @@ +mod common; + +use serde_json::json; +use std::time::Duration; +use uuid::{Uuid, Version}; + +#[test] +fn track_posts_valid_events_to_the_events_endpoint() { + let (api_url, requests) = common::api(); + logdash::create_logdash( + logdash::Config::default() + .api_key("key".into()) + .api_url(api_url) + .app("desktop".into()), + ); + + logdash::track("Agent Run", &[], None); + logdash::track("agent_run", &[], Some("ada@")); + logdash::track( + "agent_run", + &[ + ("agent", "support".into()), + ("steps", 4.into()), + ("success", true.into()), + ], + Some("42"), + ); + logdash::track("app_opened", &[], None); + + // The worker sends events in order, so a dropped one would have arrived first. + let next_request = || requests.recv_timeout(Duration::from_secs(5)).unwrap(); + let mut agent_run = next_request(); + assert!(agent_run.line.starts_with("POST /events ")); + assert!(agent_run.headers.contains(&"project-api-key: key".into())); + assert!( + agent_run + .headers + .contains(&"content-type: application/json; charset=utf-8".into()) + ); + let agent_run_id = agent_run + .body + .as_object_mut() + .unwrap() + .remove("id") + .unwrap(); + assert_eq!( + Uuid::parse_str(agent_run_id.as_str().unwrap()) + .unwrap() + .get_version(), + Some(Version::Random) + ); + assert_eq!( + agent_run.body, + json!({ + "name": "agent_run", + "props": { "agent": "support", "steps": 4, "success": true }, + "userId": "42", + "app": "desktop" + }) + ); + + let mut app_opened = next_request(); + let app_opened_id = app_opened.body.as_object_mut().unwrap().remove("id"); + assert_ne!(app_opened_id, Some(agent_run_id)); + assert_eq!( + app_opened.body, + json!({ "name": "app_opened", "app": "desktop" }) + ); +}