Skip to content

Commit b581e28

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 4b5f845 commit b581e28

3 files changed

Lines changed: 310 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" }
@@ -36,6 +40,9 @@ toml = "0.8"
3640
handlebars = "6.3"
3741
regex = "1"
3842

43+
# Quickwit integration (optional)
44+
reqwest = { workspace = true, optional = true }
45+
3946
[dev-dependencies]
4047
tokio-test = "0.4"
4148
tempfile = "3.27"

crates/terraphim_orchestrator/src/config.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ pub struct OrchestratorConfig {
7474
/// Mention-driven dispatch configuration.
7575
#[serde(default)]
7676
pub mentions: Option<MentionConfig>,
77+
/// Quickwit log shipping configuration (only available with quickwit feature).
78+
#[cfg(feature = "quickwit")]
79+
#[serde(default)]
80+
pub quickwit: Option<QuickwitConfig>,
7781
}
7882

7983
/// Configuration for posting agent output to Gitea issues.
@@ -107,6 +111,65 @@ fn default_poll_modulo() -> u64 {
107111
2
108112
}
109113

114+
/// Quickwit log shipping configuration.
115+
#[cfg(feature = "quickwit")]
116+
#[derive(Debug, Clone, Serialize, Deserialize)]
117+
pub struct QuickwitConfig {
118+
/// Whether Quickwit logging is enabled.
119+
#[serde(default = "default_quickwit_enabled")]
120+
pub enabled: bool,
121+
/// Quickwit API endpoint.
122+
#[serde(default = "default_quickwit_endpoint")]
123+
pub endpoint: String,
124+
/// Index ID for log ingestion.
125+
#[serde(default = "default_quickwit_index_id")]
126+
pub index_id: String,
127+
/// Maximum documents per batch.
128+
#[serde(default = "default_quickwit_batch_size")]
129+
pub batch_size: usize,
130+
/// Seconds between forced flushes.
131+
#[serde(default = "default_quickwit_flush_interval_secs")]
132+
pub flush_interval_secs: u64,
133+
}
134+
135+
#[cfg(feature = "quickwit")]
136+
impl Default for QuickwitConfig {
137+
fn default() -> Self {
138+
Self {
139+
enabled: default_quickwit_enabled(),
140+
endpoint: default_quickwit_endpoint(),
141+
index_id: default_quickwit_index_id(),
142+
batch_size: default_quickwit_batch_size(),
143+
flush_interval_secs: default_quickwit_flush_interval_secs(),
144+
}
145+
}
146+
}
147+
148+
#[cfg(feature = "quickwit")]
149+
fn default_quickwit_enabled() -> bool {
150+
false
151+
}
152+
153+
#[cfg(feature = "quickwit")]
154+
fn default_quickwit_endpoint() -> String {
155+
"http://127.0.0.1:7280".to_string()
156+
}
157+
158+
#[cfg(feature = "quickwit")]
159+
fn default_quickwit_index_id() -> String {
160+
"adf-logs".to_string()
161+
}
162+
163+
#[cfg(feature = "quickwit")]
164+
fn default_quickwit_batch_size() -> usize {
165+
100
166+
}
167+
168+
#[cfg(feature = "quickwit")]
169+
fn default_quickwit_flush_interval_secs() -> u64 {
170+
5
171+
}
172+
110173
/// Lightweight reference to an SFIA skill code and level.
111174
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112175
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)