Skip to content

Commit 5a825d6

Browse files
committed
feat: add QuickwitSink module and config for ADF log shipping
Feature-gated behind quickwit Cargo feature. - quickwit.rs: async batched NDJSON shipper (mpsc channel + tokio background task) - config.rs: QuickwitConfig struct with defaults (endpoint, index_id, batch_size, flush_interval) - Cargo.toml: quickwit feature flag with optional reqwest dependency Refs #330, #333
1 parent 9bd7c19 commit 5a825d6

3 files changed

Lines changed: 314 additions & 0 deletions

File tree

crates/terraphim_orchestrator/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ description = "AI Dark Factory orchestrator wiring spawner, router, supervisor i
77
license = "Apache-2.0"
88
repository = "https://github.com/terraphim/terraphim-ai"
99

10+
[features]
11+
default = []
12+
quickwit = ["dep:reqwest"]
13+
1014
[dependencies]
1115
# Terraphim internal crates
1216
terraphim_spawner = { path = "../terraphim_spawner", version = "1.0.0" }
@@ -57,6 +61,9 @@ hex = "0.4"
5761
opendal = { version = "0.54", default-features = false }
5862
terraphim_persistence = { path = "../terraphim_persistence", version = "1.4.10", features = ["sqlite"] }
5963

64+
# Quickwit integration (optional)
65+
reqwest = { workspace = true, optional = true }
66+
6067
[dev-dependencies]
6168
tokio-test = "0.4"
6269
tempfile = { workspace = true }

crates/terraphim_orchestrator/src/config.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,19 @@ pub struct OrchestratorConfig {
7878
/// Mention-driven dispatch configuration.
7979
#[serde(default)]
8080
pub mentions: Option<MentionConfig>,
81+
<<<<<<< HEAD
8182
/// Webhook configuration for real-time mention dispatch.
8283
#[serde(default)]
8384
pub webhook: Option<WebhookConfig>,
8485
/// Path to persona role configuration JSON for terraphim-agent.
8586
#[serde(default)]
8687
pub role_config_path: Option<PathBuf>,
88+
=======
89+
/// Quickwit log shipping configuration (only available with quickwit feature).
90+
#[cfg(feature = "quickwit")]
91+
#[serde(default)]
92+
pub quickwit: Option<QuickwitConfig>,
93+
>>>>>>> b581e28b (feat: add QuickwitSink module and config for ADF log shipping)
8794
}
8895

8996
/// Configuration for posting agent output to Gitea issues.
@@ -117,6 +124,7 @@ fn default_poll_modulo() -> u64 {
117124
2
118125
}
119126

127+
<<<<<<< HEAD
120128
fn default_max_dispatches_per_tick() -> u32 {
121129
3
122130
}
@@ -140,6 +148,65 @@ fn default_webhook_bind() -> String {
140148
"0.0.0.0:9090".to_string()
141149
}
142150

151+
/// Quickwit log shipping configuration.
152+
#[cfg(feature = "quickwit")]
153+
#[derive(Debug, Clone, Serialize, Deserialize)]
154+
pub struct QuickwitConfig {
155+
/// Whether Quickwit logging is enabled.
156+
#[serde(default = "default_quickwit_enabled")]
157+
pub enabled: bool,
158+
/// Quickwit API endpoint.
159+
#[serde(default = "default_quickwit_endpoint")]
160+
pub endpoint: String,
161+
/// Index ID for log ingestion.
162+
#[serde(default = "default_quickwit_index_id")]
163+
pub index_id: String,
164+
/// Maximum documents per batch.
165+
#[serde(default = "default_quickwit_batch_size")]
166+
pub batch_size: usize,
167+
/// Seconds between forced flushes.
168+
#[serde(default = "default_quickwit_flush_interval_secs")]
169+
pub flush_interval_secs: u64,
170+
}
171+
172+
#[cfg(feature = "quickwit")]
173+
impl Default for QuickwitConfig {
174+
fn default() -> Self {
175+
Self {
176+
enabled: default_quickwit_enabled(),
177+
endpoint: default_quickwit_endpoint(),
178+
index_id: default_quickwit_index_id(),
179+
batch_size: default_quickwit_batch_size(),
180+
flush_interval_secs: default_quickwit_flush_interval_secs(),
181+
}
182+
}
183+
}
184+
185+
#[cfg(feature = "quickwit")]
186+
fn default_quickwit_enabled() -> bool {
187+
false
188+
}
189+
190+
#[cfg(feature = "quickwit")]
191+
fn default_quickwit_endpoint() -> String {
192+
"http://127.0.0.1:7280".to_string()
193+
}
194+
195+
#[cfg(feature = "quickwit")]
196+
fn default_quickwit_index_id() -> String {
197+
"adf-logs".to_string()
198+
}
199+
200+
#[cfg(feature = "quickwit")]
201+
fn default_quickwit_batch_size() -> usize {
202+
100
203+
}
204+
205+
#[cfg(feature = "quickwit")]
206+
fn default_quickwit_flush_interval_secs() -> u64 {
207+
5
208+
}
209+
143210
/// Lightweight reference to an SFIA skill code and level.
144211
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145212
pub struct SfiaSkillRef {
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
//! Quickwit log shipping for ADF orchestrator
2+
//!
3+
//! Feature-gated behind `quickwit` feature flag.
4+
//! Provides async log shipping to Quickwit search engine.
5+
6+
use serde::{Deserialize, Serialize};
7+
use std::sync::Arc;
8+
use tokio::sync::mpsc;
9+
use tracing::{error, warn};
10+
11+
/// Log document structure for Quickwit ingestion
12+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13+
pub struct LogDocument {
14+
/// ISO 8601 timestamp
15+
pub timestamp: String,
16+
/// Log level (INFO, WARN, ERROR)
17+
pub level: String,
18+
/// Name of the agent
19+
pub agent_name: String,
20+
/// Agent layer (Safety, Core, Growth)
21+
pub layer: String,
22+
/// Source of the log (orchestrator, stdout, stderr)
23+
pub source: String,
24+
/// Log message
25+
pub message: String,
26+
/// Model used (if applicable)
27+
#[serde(skip_serializing_if = "Option::is_none")]
28+
pub model: Option<String>,
29+
/// Persona used (if applicable)
30+
#[serde(skip_serializing_if = "Option::is_none")]
31+
pub persona: Option<String>,
32+
/// Exit code (if applicable)
33+
#[serde(skip_serializing_if = "Option::is_none")]
34+
pub exit_code: Option<i32>,
35+
/// Trigger event (if applicable)
36+
#[serde(skip_serializing_if = "Option::is_none")]
37+
pub trigger: Option<String>,
38+
/// Wall time in seconds (if applicable)
39+
#[serde(skip_serializing_if = "Option::is_none")]
40+
pub wall_time_secs: Option<f64>,
41+
/// Flow name (if applicable)
42+
#[serde(skip_serializing_if = "Option::is_none")]
43+
pub flow_name: Option<String>,
44+
/// Extra fields (if applicable)
45+
#[serde(skip_serializing_if = "Option::is_none")]
46+
pub extra: Option<serde_json::Value>,
47+
}
48+
49+
/// Error type for QuickwitSink operations
50+
#[derive(Debug)]
51+
pub enum QuickwitError {
52+
/// Failed to send document to the channel
53+
SendError(String),
54+
/// HTTP client error
55+
HttpError(String),
56+
/// Serialization error
57+
SerializationError(String),
58+
}
59+
60+
impl std::fmt::Display for QuickwitError {
61+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62+
match self {
63+
QuickwitError::SendError(msg) => write!(f, "Send error: {}", msg),
64+
QuickwitError::HttpError(msg) => write!(f, "HTTP error: {}", msg),
65+
QuickwitError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
66+
}
67+
}
68+
}
69+
70+
impl std::error::Error for QuickwitError {}
71+
72+
impl<T> From<mpsc::error::SendError<T>> for QuickwitError {
73+
fn from(_: mpsc::error::SendError<T>) -> Self {
74+
QuickwitError::SendError("Channel closed".to_string())
75+
}
76+
}
77+
78+
/// Async log shipper for Quickwit
79+
#[derive(Debug, Clone)]
80+
pub struct QuickwitSink {
81+
tx: mpsc::Sender<LogDocument>,
82+
}
83+
84+
impl QuickwitSink {
85+
/// Create a new QuickwitSink and spawn the background ingestion task
86+
///
87+
/// # Arguments
88+
/// * `endpoint` - Quickwit API endpoint (e.g., "http://127.0.0.1:7280")
89+
/// * `index_id` - Index name (e.g., "adf-logs")
90+
/// * `batch_size` - Maximum documents per batch
91+
/// * `flush_interval_secs` - Seconds between forced flushes
92+
pub fn new(
93+
endpoint: String,
94+
index_id: String,
95+
batch_size: usize,
96+
flush_interval_secs: u64,
97+
) -> Self {
98+
let (tx, mut rx) = mpsc::channel::<LogDocument>(4096);
99+
let client = reqwest::Client::new();
100+
let ingest_url = format!("{}/api/v1/{}/ingest", endpoint, index_id);
101+
102+
// Spawn background task for batching and sending
103+
tokio::spawn(async move {
104+
let mut buffer: Vec<LogDocument> = Vec::with_capacity(batch_size);
105+
let mut last_flush = tokio::time::Instant::now();
106+
let flush_interval = tokio::time::Duration::from_secs(flush_interval_secs);
107+
108+
loop {
109+
let timeout = tokio::time::sleep_until(last_flush + flush_interval);
110+
tokio::pin!(timeout);
111+
112+
tokio::select! {
113+
Some(doc) = rx.recv() => {
114+
buffer.push(doc);
115+
if buffer.len() >= batch_size {
116+
Self::flush_batch(&client, &ingest_url, &mut buffer).await;
117+
last_flush = tokio::time::Instant::now();
118+
}
119+
}
120+
_ = &mut timeout => {
121+
if !buffer.is_empty() {
122+
Self::flush_batch(&client, &ingest_url, &mut buffer).await;
123+
last_flush = tokio::time::Instant::now();
124+
}
125+
}
126+
else => {
127+
// Channel closed, flush remaining and exit
128+
if !buffer.is_empty() {
129+
Self::flush_batch(&client, &ingest_url, &mut buffer).await;
130+
}
131+
break;
132+
}
133+
}
134+
}
135+
});
136+
137+
Self { tx }
138+
}
139+
140+
/// Send a log document to the sink
141+
///
142+
/// Returns an error if the channel is closed
143+
pub async fn send(&self, doc: LogDocument) -> Result<(), QuickwitError> {
144+
self.tx.send(doc).await.map_err(|e| e.into())
145+
}
146+
147+
/// Shutdown the sink, flushing any pending documents
148+
///
149+
/// Note: This drops the sender, causing the background task to flush
150+
/// remaining documents and exit
151+
pub fn shutdown(self) {
152+
// Dropping the sender will cause the background task to exit
153+
// after flushing any remaining documents
154+
drop(self.tx);
155+
}
156+
157+
/// Flush a batch of documents to Quickwit
158+
///
159+
/// Fire-and-forget: errors are logged but not propagated
160+
async fn flush_batch(
161+
client: &reqwest::Client,
162+
url: &str,
163+
buffer: &mut Vec<LogDocument>,
164+
) {
165+
if buffer.is_empty() {
166+
return;
167+
}
168+
169+
// Build NDJSON payload
170+
let mut ndjson = String::new();
171+
for doc in buffer.drain(..) {
172+
match serde_json::to_string(&doc) {
173+
Ok(json) => {
174+
ndjson.push_str(&json);
175+
ndjson.push('\n');
176+
}
177+
Err(e) => {
178+
warn!(error = %e, "failed to serialize log document");
179+
}
180+
}
181+
}
182+
183+
// Send to Quickwit
184+
match client
185+
.post(url)
186+
.header("Content-Type", "application/x-ndjson")
187+
.body(ndjson)
188+
.send()
189+
.await
190+
{
191+
Ok(response) => {
192+
if !response.status().is_success() {
193+
let status = response.status();
194+
let body = response.text().await.unwrap_or_default();
195+
warn!(status = %status, body = %body, "Quickwit ingest failed");
196+
}
197+
}
198+
Err(e) => {
199+
warn!(error = %e, "failed to send logs to Quickwit");
200+
}
201+
}
202+
}
203+
}
204+
205+
#[cfg(test)]
206+
mod tests {
207+
use super::*;
208+
209+
#[test]
210+
fn test_log_document_serialization() {
211+
let doc = LogDocument {
212+
timestamp: "2024-01-01T00:00:00Z".to_string(),
213+
level: "INFO".to_string(),
214+
agent_name: "test-agent".to_string(),
215+
layer: "Safety".to_string(),
216+
source: "orchestrator".to_string(),
217+
message: "test message".to_string(),
218+
model: Some("gpt-4".to_string()),
219+
persona: None,
220+
exit_code: Some(0),
221+
trigger: None,
222+
wall_time_secs: Some(1.5),
223+
flow_name: None,
224+
extra: None,
225+
};
226+
227+
let json = serde_json::to_string(&doc).unwrap();
228+
assert!(json.contains("test-agent"));
229+
assert!(json.contains("INFO"));
230+
assert!(json.contains("gpt-4"));
231+
// None fields should be skipped
232+
assert!(!json.contains("persona"));
233+
}
234+
235+
#[test]
236+
fn test_quickwit_error_display() {
237+
let err = QuickwitError::HttpError("connection refused".to_string());
238+
assert_eq!(err.to_string(), "HTTP error: connection refused");
239+
}
240+
}

0 commit comments

Comments
 (0)