featherbit/plugins/native/
lago.rs1use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24
25use async_trait::async_trait;
26use serde_json::{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};
34use crate::vars;
35
36pub struct LagoPlugin {
38 sink: BatchSink,
39 log_format: Option<HashMap<String, Value>>,
40 include_req_body: bool,
41 include_resp_body: bool,
42 event_transaction_id: String,
43 subscription_id: String,
44 event_code: String,
45}
46
47struct LagoFlusher {
49 client: Arc<OutboundClient>,
50 url: String,
51 token: String,
52 timeout: Duration,
53 ssl_verify: bool,
54}
55
56impl LagoPlugin {
57 pub fn from_config(
87 config: &HashMap<String, Value>,
88 resources: &Arc<PluginResources>,
89 ) -> Result<Self, String> {
90 let endpoint = required_str(config, "endpoint")?
91 .trim_end_matches('/')
92 .to_string();
93 let token = required_str(config, "token")?.to_string();
94 let event_transaction_id = required_str(config, "event_transaction_id")?.to_string();
95 let subscription_id = required_str(config, "subscription_id")?.to_string();
96 let event_code = required_str(config, "event_code")?.to_string();
97
98 let endpoint_uri = config
99 .get("endpoint_uri")
100 .and_then(|v| v.as_str())
101 .unwrap_or("/api/v1/events/batch");
102 let ssl_verify = config
103 .get("ssl_verify")
104 .and_then(|v| v.as_bool())
105 .unwrap_or(true);
106 let timeout = Duration::from_millis(
107 config
108 .get("timeout")
109 .and_then(|v| v.as_u64())
110 .unwrap_or(3000),
111 );
112
113 let log_format = parse_log_format(config)?;
114 let include_req_body = config
115 .get("include_req_body")
116 .and_then(|v| v.as_bool())
117 .unwrap_or(false);
118 let include_resp_body = config
119 .get("include_resp_body")
120 .and_then(|v| v.as_bool())
121 .unwrap_or(false);
122
123 let mut batch_cfg = BatchConfig::from_config(config).map_err(|e| format!("lago: {e}"))?;
124 if !config.contains_key("batch_max_size") {
126 batch_cfg.batch_max_size = 100;
127 }
128
129 let flusher = Arc::new(LagoFlusher {
130 client: resources.outbound.clone(),
131 url: format!("{endpoint}{endpoint_uri}"),
132 token,
133 timeout,
134 ssl_verify,
135 });
136 let sink = BatchSink::spawn("lago", batch_cfg, flusher);
137
138 Ok(Self {
139 sink,
140 log_format,
141 include_req_body,
142 include_resp_body,
143 event_transaction_id,
144 subscription_id,
145 event_code,
146 })
147 }
148}
149
150fn build_event(
152 transaction_id: &str,
153 external_subscription_id: &str,
154 code: &str,
155 timestamp: u64,
156 properties: Value,
157) -> Value {
158 json!({
159 "transaction_id": transaction_id,
160 "external_subscription_id": external_subscription_id,
161 "code": code,
162 "timestamp": timestamp,
163 "properties": properties,
164 })
165}
166
167fn build_batch_body(events: &[Value]) -> Value {
169 json!({ "events": events })
170}
171
172fn required_str<'a>(config: &'a HashMap<String, Value>, key: &str) -> Result<&'a str, String> {
173 config
174 .get(key)
175 .and_then(|v| v.as_str())
176 .filter(|s| !s.is_empty())
177 .ok_or_else(|| format!("lago: `{key}` is required"))
178}
179
180fn now_secs() -> u64 {
181 std::time::SystemTime::now()
182 .duration_since(std::time::UNIX_EPOCH)
183 .map(|d| d.as_secs())
184 .unwrap_or(0)
185}
186
187#[async_trait]
188impl BatchFlusher for LagoFlusher {
189 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
190 let body = serde_json::to_vec(&build_batch_body(entries)).map_err(|e| FlushError {
191 message: format!("failed to encode events batch: {e}"),
192 first_fail: None,
193 })?;
194
195 let req = OutboundRequest {
196 method: http::Method::POST,
197 url: self.url.clone(),
198 headers: vec![
199 ("Content-Type".to_string(), "application/json".to_string()),
200 (
201 "Authorization".to_string(),
202 format!("Bearer {}", self.token),
203 ),
204 ],
205 body: body.into(),
206 timeout: self.timeout,
207 ssl_verify: self.ssl_verify,
208 tls: None,
209 };
210
211 match self.client.request(req).await {
212 Ok(resp) if resp.status < 300 => Ok(()),
213 Ok(resp) => Err(FlushError {
214 message: format!(
215 "lago api returned status {}: {}",
216 resp.status,
217 String::from_utf8_lossy(&resp.body)
218 ),
219 first_fail: None,
220 }),
221 Err(e) => Err(FlushError {
222 message: e.to_string(),
223 first_fail: None,
224 }),
225 }
226 }
227}
228
229#[async_trait]
230impl Plugin for LagoPlugin {
231 fn plugin_type(&self) -> &str {
232 "lago"
233 }
234
235 async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
236 let transaction_id = vars::interpolate(&ctx, &self.event_transaction_id);
237 let external_subscription_id = vars::interpolate(&ctx, &self.subscription_id);
238 let properties = build_entry(
239 &ctx,
240 self.log_format.as_ref(),
241 self.include_req_body,
242 self.include_resp_body,
243 );
244 let event = build_event(
245 &transaction_id,
246 &external_subscription_id,
247 &self.event_code,
248 now_secs(),
249 properties,
250 );
251 self.sink.push(event);
252
253 Ok(PluginOutput {
254 context: ctx,
255 named_outputs: HashMap::new(),
256 })
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn cfg(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
265 pairs
266 .iter()
267 .map(|(k, v)| (k.to_string(), v.clone()))
268 .collect()
269 }
270
271 fn full_cfg() -> HashMap<String, Value> {
272 cfg(&[
273 ("endpoint", json!("http://lago:3000/")),
274 ("token", json!("secret")),
275 ("event_transaction_id", json!("req_$request_uri")),
276 ("subscription_id", json!("cus_$consumer_name")),
277 ("event_code", json!("api_calls")),
278 ])
279 }
280
281 #[test]
282 fn from_config_requires_fields() {
283 for missing in [
284 "endpoint",
285 "token",
286 "event_transaction_id",
287 "subscription_id",
288 "event_code",
289 ] {
290 let mut c = full_cfg();
291 c.remove(missing);
292 let res = LagoPlugin::from_config(&c, &PluginResources::empty());
293 let Err(e) = res else {
294 panic!("expected error when `{missing}` missing")
295 };
296 assert!(e.contains(missing));
297 }
298 }
299
300 #[tokio::test]
301 async fn from_config_ok() {
302 let p = LagoPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
303 assert_eq!(p.event_code, "api_calls");
304 assert_eq!(p.event_transaction_id, "req_$request_uri");
305 }
306
307 #[test]
308 fn event_envelope_shape() {
309 let props = json!({ "request": { "method": "GET" } });
310 let ev = build_event("txn-1", "sub-9", "api_calls", 1_700_000_000, props);
311 assert_eq!(ev["transaction_id"], "txn-1");
312 assert_eq!(ev["external_subscription_id"], "sub-9");
313 assert_eq!(ev["code"], "api_calls");
314 assert_eq!(ev["timestamp"], 1_700_000_000u64);
315 assert_eq!(ev["properties"]["request"]["method"], "GET");
316 }
317
318 #[test]
319 fn batch_body_wraps_events() {
320 let events = vec![
321 json!({ "transaction_id": "a" }),
322 json!({ "transaction_id": "b" }),
323 ];
324 let body = build_batch_body(&events);
325 assert_eq!(body["events"].as_array().unwrap().len(), 2);
326 assert_eq!(body["events"][1]["transaction_id"], "b");
327 }
328}