featherbit/plugins/native/
loggly.rs1use std::collections::HashMap;
21use std::sync::Arc;
22use std::time::Duration;
23
24use async_trait::async_trait;
25use bytes::Bytes;
26use serde_json::Value;
27
28use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
29use crate::context::Context;
30use crate::outbound::{OutboundClient, OutboundRequest};
31use crate::plugins::resources::PluginResources;
32use crate::plugins::util::log_entry::{build_entry, parse_log_format};
33use crate::plugins::{Plugin, PluginOutput, PluginResult};
34
35const DEFAULT_HOST: &str = "logs-01.loggly.com";
36
37fn build_bulk_url(host: &str, token: &str, tags: &[String]) -> String {
39 let base = if host.starts_with("http://") || host.starts_with("https://") {
40 host.trim_end_matches('/').to_string()
41 } else {
42 format!("https://{}", host.trim_end_matches('/'))
43 };
44 let tag_seg = if tags.is_empty() {
45 "featherbit".to_string()
46 } else {
47 tags.join(",")
48 };
49 format!("{}/bulk/{}/tag/{}/", base, token, tag_seg)
50}
51
52fn build_bulk_body(entries: &[Value]) -> Bytes {
55 let lines: Vec<String> = entries
56 .iter()
57 .map(|e| serde_json::to_string(e).unwrap_or_default())
58 .collect();
59 Bytes::from(lines.join("\n"))
60}
61
62struct LogglyFlusher {
64 client: Arc<OutboundClient>,
65 url: String,
66 tags_header: String,
67 ssl_verify: bool,
68 timeout: Duration,
69}
70
71#[async_trait]
72impl BatchFlusher for LogglyFlusher {
73 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
74 let body = build_bulk_body(entries);
75 let headers = vec![
76 ("Content-Type".to_string(), "application/json".to_string()),
77 ("X-LOGGLY-TAG".to_string(), self.tags_header.clone()),
78 ];
79 let req = OutboundRequest {
80 method: http::Method::POST,
81 url: self.url.clone(),
82 headers,
83 body,
84 timeout: self.timeout,
85 ssl_verify: self.ssl_verify,
86 tls: None,
87 };
88 match self.client.request(req).await {
89 Ok(resp) if resp.status == 200 => Ok(()),
90 Ok(resp) => Err(FlushError {
91 message: format!("loggly returned status {}", resp.status),
92 first_fail: None,
93 }),
94 Err(e) => Err(FlushError {
95 message: e.to_string(),
96 first_fail: None,
97 }),
98 }
99 }
100}
101
102pub struct LogglyPlugin {
104 sink: BatchSink,
105 log_format: Option<HashMap<String, Value>>,
106 include_req_body: bool,
107 include_resp_body: bool,
108}
109
110impl LogglyPlugin {
111 pub fn from_config(
136 config: &HashMap<String, Value>,
137 resources: &Arc<PluginResources>,
138 ) -> Result<Self, String> {
139 let token = config
140 .get("customer_token")
141 .and_then(|v| v.as_str())
142 .filter(|s| !s.is_empty())
143 .ok_or_else(|| "loggly plugin requires 'customer_token'".to_string())?
144 .to_string();
145
146 let tags: Vec<String> = config
147 .get("tags")
148 .and_then(|v| v.as_array())
149 .map(|arr| {
150 arr.iter()
151 .filter_map(|v| v.as_str().map(String::from))
152 .collect()
153 })
154 .unwrap_or_else(|| vec!["featherbit".to_string()]);
155
156 let host = config
157 .get("host")
158 .and_then(|v| v.as_str())
159 .unwrap_or(DEFAULT_HOST);
160 let url = build_bulk_url(host, &token, &tags);
161 let tags_header = if tags.is_empty() {
162 "featherbit".to_string()
163 } else {
164 tags.join(",")
165 };
166
167 let ssl_verify = config
168 .get("ssl_verify")
169 .and_then(|v| v.as_bool())
170 .unwrap_or(true);
171 let timeout = Duration::from_millis(
172 config
173 .get("timeout")
174 .and_then(|v| v.as_u64())
175 .unwrap_or(5000)
176 .max(1),
177 );
178
179 let log_format = parse_log_format(config)?;
180 let include_req_body = config
181 .get("include_req_body")
182 .and_then(|v| v.as_bool())
183 .unwrap_or(false);
184 let include_resp_body = config
185 .get("include_resp_body")
186 .and_then(|v| v.as_bool())
187 .unwrap_or(false);
188
189 let batch_cfg = BatchConfig::from_config(config)?;
190 let flusher = Arc::new(LogglyFlusher {
191 client: resources.outbound.clone(),
192 url,
193 tags_header,
194 ssl_verify,
195 timeout,
196 });
197 let sink = BatchSink::spawn("loggly", batch_cfg, flusher);
198
199 Ok(Self {
200 sink,
201 log_format,
202 include_req_body,
203 include_resp_body,
204 })
205 }
206}
207
208#[async_trait]
209impl Plugin for LogglyPlugin {
210 fn plugin_type(&self) -> &str {
211 "loggly"
212 }
213
214 async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
215 let entry = build_entry(
216 &ctx,
217 self.log_format.as_ref(),
218 self.include_req_body,
219 self.include_resp_body,
220 );
221 self.sink.push(entry);
222 Ok(PluginOutput {
223 context: ctx,
224 named_outputs: HashMap::new(),
225 })
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use serde_json::json;
233
234 fn cfg(v: Value) -> HashMap<String, Value> {
235 serde_json::from_value(v).unwrap()
236 }
237
238 #[test]
239 fn rejects_missing_token() {
240 assert!(LogglyPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
241 let c = cfg(json!({ "customer_token": "" }));
242 assert!(LogglyPlugin::from_config(&c, &PluginResources::empty()).is_err());
243 }
244
245 #[tokio::test]
246 async fn accepts_token() {
247 let c = cfg(json!({ "customer_token": "tok" }));
248 assert!(LogglyPlugin::from_config(&c, &PluginResources::empty()).is_ok());
249 }
250
251 #[test]
252 fn bulk_url_defaults_to_https_host() {
253 let url = build_bulk_url(DEFAULT_HOST, "tok", &["a".to_string(), "b".to_string()]);
254 assert_eq!(url, "https://logs-01.loggly.com/bulk/tok/tag/a,b/");
255 }
256
257 #[test]
258 fn bulk_url_respects_explicit_scheme() {
259 let url = build_bulk_url("http://loggly.local", "tok", &["x".to_string()]);
260 assert_eq!(url, "http://loggly.local/bulk/tok/tag/x/");
261 }
262
263 #[test]
264 fn bulk_body_is_newline_delimited_json() {
265 let entries = vec![json!({"a": 1}), json!({"a": 2})];
266 let body = build_bulk_body(&entries);
267 let text = String::from_utf8(body.to_vec()).unwrap();
268 let lines: Vec<&str> = text.split('\n').collect();
269 assert_eq!(lines.len(), 2);
270 let second: Value = serde_json::from_str(lines[1]).unwrap();
271 assert_eq!(second["a"], 2);
272 }
273}