forge-ops-tracker 0.8.0

Rust error reporting client for a ForgeOps instance.
Documentation
// Delivers one event over HTTP. Every failure mode: DNS, connection, timeout, TLS, a non-2xx
// response: is caught here and turned into a `false` return rather than a propagated error,
// since a broken or unreachable tracker must never be able to break the host app. Ported from
// gems/forge_ops_tracker/lib/forge_ops_tracker/client.rb.
//
// Uses `ureq` rather than std alone: unlike Python's urllib, Go's net/http, or Java's
// java.net.http, Rust's standard library has no HTTP client at all. See README.md's
// "Dependencies" section.

use std::sync::Arc;
use std::sync::RwLock;

use crate::configuration::Configuration;
use crate::event_builder::Event;

pub struct Client {
    configuration: Arc<RwLock<Configuration>>,
    agent: ureq::Agent,
}

impl Client {
    pub fn new(configuration: Arc<RwLock<Configuration>>) -> Self {
        let timeout = configuration.read().unwrap().timeout;
        let agent = ureq::AgentBuilder::new().timeout(timeout).build();
        Client {
            configuration,
            agent,
        }
    }

    pub fn deliver(&self, event: &Event) -> bool {
        let (uri, api_key) = {
            let config = self.configuration.read().unwrap();
            (config.ingestion_uri(), config.api_key())
        };
        self.post(uri, api_key, &event.to_json())
    }

    /// Same delivery contract as `deliver`, against the DSN's performance_samples endpoint (see
    /// `Configuration::performance_samples_uri`). `body` is the whole request body, already
    /// encoded: `{"samples":[...]}`, the shape Api::V1::PerformanceSamplesController expects.
    pub fn deliver_performance_samples(&self, body: &str) -> bool {
        let (uri, api_key) = {
            let config = self.configuration.read().unwrap();
            (config.performance_samples_uri(), config.api_key())
        };
        self.post(uri, api_key, body)
    }

    /// Delivers one finished trace (`{"trace_id":...,"spans":[...]}`, already encoded) to the
    /// DSN's spans endpoint.
    pub fn deliver_spans(&self, body: &str) -> bool {
        let (uri, api_key) = {
            let config = self.configuration.read().unwrap();
            (config.spans_uri(), config.api_key())
        };
        self.post(uri, api_key, body)
    }

    /// Delivers one batch of individual `capture_metric` entries (`{"metrics":[...]}`, already
    /// encoded) to the DSN's custom metrics endpoint.
    pub fn deliver_metrics(&self, body: &str) -> bool {
        let (uri, api_key) = {
            let config = self.configuration.read().unwrap();
            (config.custom_metrics_uri(), config.api_key())
        };
        self.post(uri, api_key, body)
    }

    /// The same for infrastructure readings.
    pub fn deliver_infrastructure_metrics(&self, body: &str) -> bool {
        let (uri, api_key) = {
            let config = self.configuration.read().unwrap();
            (config.infrastructure_metrics_uri(), config.api_key())
        };
        self.post(uri, api_key, body)
    }

    fn post(&self, uri: Option<String>, api_key: Option<String>, body: &str) -> bool {
        let (Some(uri), Some(api_key)) = (uri, api_key) else {
            return false;
        };

        let result = self
            .agent
            .post(&uri)
            .set("Authorization", &format!("Bearer {api_key}"))
            .set("Content-Type", "application/json")
            .send_string(body);

        match result {
            Ok(response) => (200..300).contains(&response.status()),
            Err(_) => false,
        }
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;
    use std::sync::mpsc;
    use std::time::Duration;

    use super::*;

    fn test_event() -> Event {
        Event {
            exception_class: "std::io::Error".to_string(),
            message: "boom".to_string(),
            backtrace: vec![],
            occurred_at: "2024-01-15T10:30:00Z".to_string(),
            environment: "production".to_string(),
            release: None,
            server_name: None,
            context: HashMap::new(),
            tags: HashMap::new(),
            sdk_name: "rust".to_string(),
            user: None,
            breadcrumbs: vec![],
        }
    }

    // A minimal single-request HTTP/1.1 server over a raw TcpListener: avoids adding an HTTP
    // server crate as a dev-dependency just to answer one POST in these tests. Reads the request,
    // hands the caller its method/path/headers/body via the channel, and always answers with the
    // given status code.
    fn serve_once(status: u16) -> (String, mpsc::Receiver<(String, String)>) {
        use std::io::Write;
        use std::net::TcpListener;

        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let (tx, rx) = mpsc::channel();

        std::thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            stream
                .set_read_timeout(Some(Duration::from_secs(2)))
                .unwrap();

            let received = crate::test_support::read_full_request(&mut stream);
            let text = String::from_utf8_lossy(&received).into_owned();
            let mut lines = text.lines();
            let request_line = lines.next().unwrap_or("").to_string();
            let auth_header = lines
                .find(|l| l.to_lowercase().starts_with("authorization:"))
                .unwrap_or("")
                .to_string();
            let _ = tx.send((request_line, auth_header));

            let body = "{}";
            let response = format!(
                "HTTP/1.1 {status} OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
                body.len()
            );
            let _ = stream.write_all(response.as_bytes());
        });

        (format!("http://{addr}/api/v1/events"), rx)
    }

    #[test]
    fn deliver_sends_authorized_request_and_returns_true_on_2xx() {
        let (base_uri, rx) = serve_once(200);
        let dsn = base_uri.replacen("http://", "http://secret-key@", 1);

        let mut config = Configuration::new();
        config.dsn = Some(dsn);
        config.timeout = Duration::from_secs(2);
        let client = Client::new(Arc::new(RwLock::new(config)));

        let ok = client.deliver(&test_event());
        assert!(ok);

        let (request_line, auth_header) = rx
            .recv_timeout(Duration::from_secs(2))
            .expect("server never received a request");
        assert!(
            request_line.starts_with("POST "),
            "request line = {request_line:?}"
        );
        assert!(
            auth_header.to_lowercase().contains("bearer secret-key"),
            "authorization header = {auth_header:?}"
        );
    }

    #[test]
    fn deliver_returns_false_on_non_2xx() {
        let (base_uri, _rx) = serve_once(500);
        let dsn = base_uri.replacen("http://", "http://key@", 1);

        let mut config = Configuration::new();
        config.dsn = Some(dsn);
        config.timeout = Duration::from_secs(2);
        let client = Client::new(Arc::new(RwLock::new(config)));

        assert!(!client.deliver(&test_event()));
    }

    #[test]
    fn deliver_returns_false_when_unreachable() {
        let mut config = Configuration::new();
        config.dsn = Some("http://key@127.0.0.1:1/events".to_string());
        config.timeout = Duration::from_millis(200);
        let client = Client::new(Arc::new(RwLock::new(config)));

        assert!(!client.deliver(&test_event()));
    }

    #[test]
    fn deliver_returns_false_with_no_dsn() {
        let config = Configuration::new();
        let client = Client::new(Arc::new(RwLock::new(config)));

        assert!(!client.deliver(&test_event()));
    }
}