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, LogFormat};
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<LogFormat>,
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 fn reads_response_body(&self) -> bool {
215 crate::plugins::util::log_entry::reads_response_body(
216 self.log_format.as_ref(),
217 self.include_resp_body,
218 )
219 }
220
221 async fn execute(&self, ctx: Context) -> PluginResult {
222 let entry = build_entry(
223 &ctx,
224 self.log_format.as_ref(),
225 self.include_req_body,
226 self.include_resp_body,
227 );
228 self.sink.push(entry);
229 Ok(PluginOutput::success(ctx))
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use serde_json::json;
237
238 fn cfg(v: Value) -> HashMap<String, Value> {
239 serde_json::from_value(v).unwrap()
240 }
241
242 #[test]
243 fn rejects_missing_token() {
244 assert!(LogglyPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
245 let c = cfg(json!({ "customer_token": "" }));
246 assert!(LogglyPlugin::from_config(&c, &PluginResources::empty()).is_err());
247 }
248
249 #[tokio::test]
250 async fn accepts_token() {
251 let c = cfg(json!({ "customer_token": "tok" }));
252 assert!(LogglyPlugin::from_config(&c, &PluginResources::empty()).is_ok());
253 }
254
255 #[test]
256 fn bulk_url_defaults_to_https_host() {
257 let url = build_bulk_url(DEFAULT_HOST, "tok", &["a".to_string(), "b".to_string()]);
258 assert_eq!(url, "https://logs-01.loggly.com/bulk/tok/tag/a,b/");
259 }
260
261 #[test]
262 fn bulk_url_respects_explicit_scheme() {
263 let url = build_bulk_url("http://loggly.local", "tok", &["x".to_string()]);
264 assert_eq!(url, "http://loggly.local/bulk/tok/tag/x/");
265 }
266
267 #[test]
268 fn bulk_body_is_newline_delimited_json() {
269 let entries = vec![json!({"a": 1}), json!({"a": 2})];
270 let body = build_bulk_body(&entries);
271 let text = String::from_utf8(body.to_vec()).unwrap();
272 let lines: Vec<&str> = text.split('\n').collect();
273 assert_eq!(lines.len(), 2);
274 let second: Value = serde_json::from_str(lines[1]).unwrap();
275 assert_eq!(second["a"], 2);
276 }
277}