safeapp-logger 0.2.1

SafeApp logging service
Documentation
use anyhow::{Context, Result as AnyhowResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
#[cfg(feature = "worker-v0-5")]
use worker::{Env, Fetch, Method, Request, RequestInit, console_error};
#[cfg(feature = "worker-v0-4")]
use worker_v4::{Env, Fetch, Method, Request, RequestInit, console_error};

/// Severity levels for log messages.
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub enum Severity {
    Warn,
    Error,
    Info,
    Debug,
}

impl ToString for Severity {
    fn to_string(&self) -> String {
        match self {
            Severity::Warn => "warn".to_string(),
            Severity::Error => "error".to_string(),
            Severity::Info => "info".to_string(),
            Severity::Debug => "debug".to_string(),
        }
    }
}

/// Payload structure for log messages.
#[derive(Serialize, Deserialize)]
struct Payload {
    service: String,
    route: String,
    severity: Severity,
    message: String,
    code: u16,
    #[serde(skip_serializing_if = "Option::is_none")]
    timestamp: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    context: Option<String>,
}

impl Payload {
    /// Creates a new `Payload` with default values for optional fields.
    fn new(
        service: impl Into<String>,
        route: impl Into<String>,
        severity: Severity,
        message: impl Into<String>,
        code: u16,
        context: Option<JsonValue>,
    ) -> Self {
        Payload {
            service: service.into(),
            route: route.into(),
            severity,
            message: message.into(),
            code,
            timestamp: None, // Will be set in `save_log` if not provided
            context: context
                .and_then(|v| serde_json::to_string(&v).ok())
                .filter(|s| !s.is_empty() && s != "{}"),
        }
    }
}

/// Saves a log message to the logging service.
///
/// Automatically determines the service name from the `SERVICE_NAME` environment variable
/// if not provided. Sends the log to the logger service's `/log` endpoint via HTTP.
/// The timestamp is automatically set to the current time if not provided.
///
/// # Arguments
/// - `env`: The Cloudflare Workers environment for accessing variables.
/// - `url`: The route or URL associated with the log.
/// - `message`: The log message.
/// - `code`: The HTTP status code or error code.
/// - `data`: Optional JSON context data.
/// - `severity`: The severity of the log.
///
/// # Returns
/// - `Ok(())` if the log was sent successfully.
/// - `Err(anyhow::Error)` if the operation failed.
///
/// # Environment Variables
/// - `SERVICE_NAME`: The name of the service (optional, defaults to "unknown").
/// - `LOGGER_URL`: The base URL of the logger service (required, e.g., "https://logger.example.com").
/// - `LOGGER_AUTH`: The authentication token for the logger service (required, e.g., "zoK86OpyGp2R+ia2RcC4TyXLKHZQ8Gpu48CQekQCcDM=").
///
/// # Example
/// ```rust
/// use serde_json::json;
/// use worker::Env;
/// use logging::{save_log, Severity};
///
/// async fn example(env: &Env) -> anyhow::Result<()> {
///     save_log(
///         env,
///         "/api/example",
///         "Something happened",
///         200,
///         &json!({"user_id": 123}),
///         Severity::Info,
///     ).await?;
///     Ok(())
/// }
/// ```
pub async fn save_log(
    env: &Env,
    url: &str,
    message: &str,
    code: u16,
    data: &JsonValue,
    severity: Severity,
) -> AnyhowResult<()> {
    // Determine service name from env or fallback
    let service_name = env
        .var("SERVICE_NAME")
        .map(|v| v.to_string())
        .unwrap_or_else(|_| "unknown".to_string());

    // Get logger URL from environment
    let logger_url = env
        .var("LOGGER_URL")
        .context("LOGGER_URL environment variable not set")?
        .to_string();

    // Get logger auth token from environment
    let logger_auth = env
        .var("LOGGER_AUTH")
        .context("LOGGER_AUTH environment variable not set")?
        .to_string();

    // Create payload
    let payload = Payload::new(
        service_name,
        url,
        severity,
        message,
        code,
        Some(data.clone()).filter(|v| !v.is_null() && v != &json!({})),
    );

    // Set timestamp if not provided
    let payload = Payload {
        timestamp: Some(chrono::Utc::now().to_rfc3339()),
        ..payload
    };

    // Serialize payload
    let body = serde_json::to_string(&payload).context("Failed to serialize log payload")?;

    // Construct HTTP request
    let mut headers = worker::Headers::new();
    headers
        .append("Content-Type", "application/json")
        .context("Failed to set Content-Type header")?;
    headers
        .append("Authorization", &format!("Basic {}", logger_auth))
        .context("Failed to set Authorization header")?;

    let req = Request::new_with_init(
        &format!("{}/log", logger_url),
        &RequestInit {
            method: Method::Post,
            body: Some(worker::wasm_bindgen::JsValue::from_str(&body)),
            headers,
            ..Default::default()
        },
    )
    .context("Failed to create HTTP request")?;

    // Send request
    let mut resp = Fetch::Request(req)
        .send()
        .await
        .context("Failed to send log request")?;

    // Check response status
    if !(200..=299).contains(&resp.status_code()) {
        let status = resp.status_code();
        let text = resp
            .text()
            .await
            .unwrap_or_else(|_| "No response body".to_string());
        console_error!("Failed to send log: status {}, response: {}", status, text);
        return Err(anyhow::anyhow!(
            "Log request failed with status {}: {}",
            status,
            text
        ));
    }

    Ok(())
}