safeapp_logger/
lib.rs

1use anyhow::{Context, Result as AnyhowResult};
2use serde::{Deserialize, Serialize};
3use serde_json::{Value as JsonValue, json};
4#[cfg(feature = "worker-v0-5")]
5use worker::{Env, Fetch, Method, Request, RequestInit, console_error};
6#[cfg(feature = "worker-v0-4")]
7use worker_v4::{Env, Fetch, Method, Request, RequestInit, console_error};
8
9/// Severity levels for log messages.
10#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
11pub enum Severity {
12    Warn,
13    Error,
14    Info,
15    Debug,
16}
17
18impl ToString for Severity {
19    fn to_string(&self) -> String {
20        match self {
21            Severity::Warn => "warn".to_string(),
22            Severity::Error => "error".to_string(),
23            Severity::Info => "info".to_string(),
24            Severity::Debug => "debug".to_string(),
25        }
26    }
27}
28
29/// Payload structure for log messages.
30#[derive(Serialize, Deserialize)]
31struct Payload {
32    service: String,
33    route: String,
34    severity: Severity,
35    message: String,
36    code: u16,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    timestamp: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    context: Option<String>,
41}
42
43impl Payload {
44    /// Creates a new `Payload` with default values for optional fields.
45    fn new(
46        service: impl Into<String>,
47        route: impl Into<String>,
48        severity: Severity,
49        message: impl Into<String>,
50        code: u16,
51        context: Option<JsonValue>,
52    ) -> Self {
53        Payload {
54            service: service.into(),
55            route: route.into(),
56            severity,
57            message: message.into(),
58            code,
59            timestamp: None, // Will be set in `save_log` if not provided
60            context: context
61                .and_then(|v| serde_json::to_string(&v).ok())
62                .filter(|s| !s.is_empty() && s != "{}"),
63        }
64    }
65}
66
67/// Saves a log message to the logging service.
68///
69/// Automatically determines the service name from the `SERVICE_NAME` environment variable
70/// if not provided. Sends the log to the logger service's `/log` endpoint via HTTP.
71/// The timestamp is automatically set to the current time if not provided.
72///
73/// # Arguments
74/// - `env`: The Cloudflare Workers environment for accessing variables.
75/// - `service_name`: Optional service name; defaults to `SERVICE_NAME` env variable.
76/// - `url`: The route or URL associated with the log.
77/// - `message`: The log message.
78/// - `code`: The HTTP status code or error code.
79/// - `data`: Optional JSON context data.
80/// - `severity`: The severity of the log.
81///
82/// # Returns
83/// - `Ok(())` if the log was sent successfully.
84/// - `Err(anyhow::Error)` if the operation failed.
85///
86/// # Environment Variables
87/// - `SERVICE_NAME`: The name of the service (optional, defaults to "unknown").
88/// - `LOGGER_URL`: The base URL of the logger service (required, e.g., "https://logger.example.com").
89///
90/// # Example
91/// ```rust
92/// use serde_json::json;
93/// use worker::Env;
94/// use logging::{save_log, Severity};
95///
96/// async fn example(env: &Env) -> anyhow::Result<()> {
97///     save_log(
98///         env,
99///         "/api/example",
100///         "Something happened",
101///         200,
102///         &json!({"user_id": 123}),
103///         Severity::Info,
104///     ).await?;
105///     Ok(())
106/// }
107/// ```
108pub async fn save_log(
109    env: &Env,
110    url: &str,
111    message: &str,
112    code: u16,
113    data: &JsonValue,
114    severity: Severity,
115) -> AnyhowResult<()> {
116    // Determine service name from env or fallback
117    let service_name = env
118        .var("SERVICE_NAME")
119        .map(|v| v.to_string())
120        .unwrap_or_else(|_| "unknown".to_string());
121
122    // Get logger URL from environment
123    let logger_url = env
124        .var("LOGGER_URL")
125        .context("LOGGER_URL environment variable not set")?
126        .to_string();
127
128    // Create payload
129    let payload = Payload::new(
130        service_name,
131        url,
132        severity,
133        message,
134        code,
135        Some(data.clone()).filter(|v| !v.is_null() && v != &json!({})),
136    );
137
138    // Set timestamp if not provided
139    let payload = Payload {
140        timestamp: Some(chrono::Utc::now().to_rfc3339()),
141        ..payload
142    };
143
144    // Serialize payload
145    let body = serde_json::to_string(&payload).context("Failed to serialize log payload")?;
146
147    // Construct HTTP request
148    let mut headers = worker::Headers::new();
149    headers
150        .append("Content-Type", "application/json")
151        .context("Failed to set Content-Type header")?;
152
153    let req = Request::new_with_init(
154        &format!("{}/log", logger_url),
155        &RequestInit {
156            method: Method::Post,
157            body: Some(worker::wasm_bindgen::JsValue::from_str(&body)),
158            headers,
159            ..Default::default()
160        },
161    )
162    .context("Failed to create HTTP request")?;
163
164    // Send request
165    let mut resp = Fetch::Request(req)
166        .send()
167        .await
168        .context("Failed to send log request")?;
169
170    // Check response status
171    if !(200..=299).contains(&resp.status_code()) {
172        let status = resp.status_code();
173        let text = resp
174            .text()
175            .await
176            .unwrap_or_else(|_| "No response body".to_string());
177        console_error!("Failed to send log: status {}, response: {}", status, text);
178        return Err(anyhow::anyhow!(
179            "Log request failed with status {}: {}",
180            status,
181            text
182        ));
183    }
184
185    Ok(())
186}