use crate::error::{LogError, LogResult};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub struct QuickwitConfig {
pub url: String,
pub index_id: String,
pub timeout: u64,
pub batch_size: usize,
pub enabled: bool,
pub token: Option<String>,
}
impl Default for QuickwitConfig {
fn default() -> Self {
Self {
url: String::new(),
index_id: String::new(),
timeout: 30,
batch_size: 100,
enabled: false,
token: None,
}
}
}
impl QuickwitConfig {
pub fn new(url: String, index_id: String) -> Self {
Self {
url,
index_id,
timeout: 30,
batch_size: 100,
enabled: true,
token: None,
}
}
pub fn with_timeout(mut self, timeout: u64) -> Self {
self.timeout = timeout;
self
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
pub fn with_token<T: Into<String>>(mut self, token: T) -> Self {
self.token = Some(token.into());
self
}
pub fn validate(&self) -> LogResult<()> {
if !self.enabled {
return Ok(());
}
if self.url.is_empty() {
return Err(LogError::config("Quickwit URL cannot be empty"));
}
if self.index_id.is_empty() {
return Err(LogError::config("Quickwit index_id cannot be empty"));
}
if !self.url.starts_with("http://") && !self.url.starts_with("https://") {
return Err(LogError::config("Quickwit URL must start with http:// or https://"));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct QuickwitLogEntry {
pub timestamp: u64,
pub level: String,
pub message: String,
pub module: Option<String>,
pub file: Option<String>,
pub line: Option<u32>,
pub process_id: Option<u32>,
pub thread_id: Option<String>,
pub custom_fields: HashMap<String, String>,
}
impl QuickwitLogEntry {
pub fn new(level: log::Level, message: String) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
timestamp,
level: level.to_string(),
message,
module: None,
file: None,
line: None,
process_id: None,
thread_id: None,
custom_fields: HashMap::new(),
}
}
pub fn from_record(record: &log::Record) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut entry = Self {
timestamp,
level: record.level().to_string(),
message: format!("{}", record.args()),
module: Some(record.target().to_string()),
file: record.file().map(|f| f.to_string()),
line: record.line(),
process_id: Some(std::process::id()),
thread_id: std::thread::current().name().map(|n| n.to_string()),
custom_fields: HashMap::new(),
};
if entry.thread_id.is_none() {
entry.thread_id = Some(format!("{:?}", std::thread::current().id()));
}
entry
}
pub fn add_field<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
self.custom_fields.insert(key.into(), value.into());
self
}
pub fn to_json(&self) -> LogResult<String> {
let escaped_message = self.message.replace('"', "\\\"");
let mut json = format!(
"{{\"timestamp\":{},\"level\":\"{}\",\"message\":\"{}\"",
self.timestamp,
self.level,
escaped_message
);
if let Some(ref module) = self.module {
json.push_str(&format!(",\"module\":\"{}\"", module));
}
if let Some(ref file) = self.file {
json.push_str(&format!(",\"file\":\"{}\"", file));
}
if let Some(line) = self.line {
json.push_str(&format!(",\"line\":{}", line));
}
if let Some(process_id) = self.process_id {
json.push_str(&format!(",\"process_id\":{}", process_id));
}
if let Some(ref thread_id) = self.thread_id {
json.push_str(&format!(",\"thread_id\":\"{}\"", thread_id));
}
for (key, value) in &self.custom_fields {
let escaped_value = value.replace('"', "\\\"");
json.push_str(&format!(
",\"{}\":\"{}\"",
key,
escaped_value
));
}
json.push('}');
Ok(json)
}
}
pub struct QuickwitClient {
config: QuickwitConfig,
client: Option<Arc<dyn HttpClient + Send + Sync>>,
}
pub trait HttpClient {
fn post(&self, url: &str, body: &str) -> LogResult<()>;
fn post_with_token(&self, url: &str, body: &str, token: Option<&str>) -> LogResult<()>;
}
pub struct SimpleHttpClient;
impl HttpClient for SimpleHttpClient {
fn post(&self, url: &str, body: &str) -> LogResult<()> {
self.post_with_token(url, body, None)
}
fn post_with_token(&self, url: &str, body: &str, token: Option<&str>) -> LogResult<()> {
use std::io::Write;
use std::net::TcpStream;
let url_parts: Vec<&str> = url.splitn(3, '/').collect();
if url_parts.len() < 3 {
return Err(LogError::custom("Invalid URL format"));
}
let host_port = url_parts[2].split('/').next().unwrap_or("");
let path = &url[url.find(host_port).unwrap() + host_port.len()..];
let (host, port) = if let Some(colon_pos) = host_port.find(':') {
(&host_port[..colon_pos], host_port[colon_pos + 1..].parse().unwrap_or(80))
} else {
(host_port, if url.starts_with("https") { 443 } else { 80 })
};
let mut stream = TcpStream::connect((host, port))
.map_err(|e| LogError::custom(format!("Failed to connect: {}", e)))?;
let mut headers = format!(
"POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}",
path, host, body.len()
);
if let Some(token) = token {
headers.push_str(&format!("\r\nAuthorization: Bearer {}", token));
}
headers.push_str("\r\n\r\n");
let request = format!("{}{}", headers, body);
stream.write_all(request.as_bytes())
.map_err(|e| LogError::custom(format!("Failed to send request: {}", e)))?;
Ok(())
}
}
impl QuickwitClient {
pub fn new(config: QuickwitConfig) -> LogResult<Self> {
config.validate()?;
Ok(Self {
config,
client: Some(Arc::new(SimpleHttpClient)),
})
}
pub fn with_client(mut self, client: Arc<dyn HttpClient + Send + Sync>) -> Self {
self.client = Some(client);
self
}
pub fn send_log(&self, entry: &QuickwitLogEntry) -> LogResult<()> {
if !self.config.enabled {
return Ok(());
}
let json = entry.to_json()?;
let url = format!("{}/api/v1/{}/ingest", self.config.url, self.config.index_id);
if let Some(ref client) = self.client {
client.post_with_token(&url, &json, self.config.token.as_deref())?;
}
Ok(())
}
pub fn send_logs(&self, entries: &[QuickwitLogEntry]) -> LogResult<()> {
if !self.config.enabled || entries.is_empty() {
return Ok(());
}
let mut json_array = String::from("[");
for (i, entry) in entries.iter().enumerate() {
if i > 0 {
json_array.push(',');
}
json_array.push_str(&entry.to_json()?);
}
json_array.push(']');
self.send_json(&json_array)
}
fn send_json(&self, json: &str) -> LogResult<()> {
let url = format!("{}/api/v1/{}/ingest", self.config.url, self.config.index_id);
if let Some(ref client) = self.client {
client.post_with_token(&url, json, self.config.token.as_deref())?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quickwit_config() {
let config = QuickwitConfig::new(
"http://localhost:7280".to_string(),
"my-index".to_string(),
);
assert!(config.validate().is_ok());
assert!(config.enabled);
}
#[test]
fn test_log_entry_json() {
let entry = QuickwitLogEntry::new(log::Level::Info, "Test message".to_string())
.add_field("custom", "value");
let json = entry.to_json().unwrap();
assert!(json.contains("Test message"));
assert!(json.contains("custom"));
}
#[test]
fn test_invalid_config() {
let config = QuickwitConfig::new(
"invalid-url".to_string(),
"my-index".to_string(),
);
assert!(config.validate().is_err());
}
}