1use anyhow::Result;
15use url::Url;
16
17use crate::datadog::client::DatadogClient;
18use crate::datadog::types::EventsResponse;
19
20pub const MAX_PAGE_LIMIT: usize = 1000;
22
23pub const HARD_CAP: usize = 10_000;
26
27#[derive(Debug, Default, Clone)]
34pub struct EventsListFilter {
35 pub query: Option<String>,
37 pub sources: Option<String>,
39 pub tags: Option<String>,
41}
42
43#[derive(Debug)]
45pub struct EventsApi<'a> {
46 client: &'a DatadogClient,
47}
48
49impl<'a> EventsApi<'a> {
50 #[must_use]
52 pub fn new(client: &'a DatadogClient) -> Self {
53 Self { client }
54 }
55
56 pub async fn list(
65 &self,
66 filter: &EventsListFilter,
67 from: &str,
68 to: &str,
69 limit: usize,
70 after: Option<&str>,
71 ) -> Result<EventsResponse> {
72 if limit > MAX_PAGE_LIMIT {
73 return Err(anyhow::anyhow!(
74 "`limit` must be <= {MAX_PAGE_LIMIT} (Datadog v2 events per-page cap; use `EventsApi::list_all` to auto-paginate across pages)"
75 ));
76 }
77 let url = build_list_url(self.client.base_url(), filter, from, to, limit, after)?;
78 self.client
79 .get_parsed(url.as_str(), "Failed to parse /api/v2/events response")
80 .await
81 }
82
83 pub async fn list_all(
100 &self,
101 filter: &EventsListFilter,
102 from: &str,
103 to: &str,
104 limit: usize,
105 ) -> Result<EventsResponse> {
106 let cap = effective_cap(limit);
107 let mut acc: Option<EventsResponse> = None;
108 let mut cursor: Option<String> = None;
109 loop {
110 let collected = acc.as_ref().map_or(0, |r| r.data.len());
111 let remaining = cap - collected;
112 let page_size = remaining.min(MAX_PAGE_LIMIT);
113 let page = self
114 .list(filter, from, to, page_size, cursor.as_deref())
115 .await?;
116 let next_cursor = page
117 .meta
118 .as_ref()
119 .and_then(|m| m.page.as_ref())
120 .and_then(|p| p.after.clone());
121 match acc.as_mut() {
122 Some(existing) => {
123 existing.data.extend(page.data);
124 existing.meta = page.meta;
125 existing.links = page.links;
126 }
127 None => acc = Some(page),
128 }
129 let collected = acc.as_ref().map_or(0, |r| r.data.len());
130 if collected >= cap || next_cursor.is_none() {
131 break;
132 }
133 cursor = next_cursor;
134 }
135 let mut result = acc.unwrap_or_default();
136 result.data.truncate(cap);
137 Ok(result)
138 }
139}
140
141fn effective_cap(limit: usize) -> usize {
144 if limit == 0 {
145 HARD_CAP
146 } else {
147 limit.min(HARD_CAP)
148 }
149}
150
151fn build_list_url(
153 base_url: &str,
154 filter: &EventsListFilter,
155 from: &str,
156 to: &str,
157 limit: usize,
158 after: Option<&str>,
159) -> Result<Url> {
160 let mut url = DatadogClient::api_url(base_url, "/api/v2/events")?;
161 {
162 let mut q = url.query_pairs_mut();
163 if let Some(query) = filter.query.as_deref() {
164 q.append_pair("filter[query]", query);
165 }
166 if let Some(sources) = filter.sources.as_deref() {
167 q.append_pair("filter[sources]", sources);
168 }
169 if let Some(tags) = filter.tags.as_deref() {
170 q.append_pair("filter[tags]", tags);
171 }
172 q.append_pair("filter[from]", from);
173 q.append_pair("filter[to]", to);
174 q.append_pair("page[limit]", &limit.to_string());
175 if let Some(cursor) = after {
176 q.append_pair("page[cursor]", cursor);
177 }
178 }
179 Ok(url)
180}
181
182#[cfg(test)]
183#[allow(clippy::unwrap_used, clippy::expect_used)]
184mod tests {
185 use super::*;
186
187 #[test]
190 fn effective_cap_zero_means_hard_cap() {
191 assert_eq!(effective_cap(0), HARD_CAP);
192 }
193
194 #[test]
195 fn effective_cap_clamps_to_hard_cap() {
196 assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
197 }
198
199 #[test]
200 fn effective_cap_passes_through_small_limits() {
201 assert_eq!(effective_cap(42), 42);
202 }
203
204 #[test]
207 fn build_list_url_appends_only_provided_filters() {
208 let filter = EventsListFilter {
209 query: Some("service:api".into()),
210 sources: None,
211 tags: None,
212 };
213 let url = build_list_url(
214 "https://api.datadoghq.com",
215 &filter,
216 "2026-04-22T09:00:00Z",
217 "2026-04-22T10:00:00Z",
218 50,
219 None,
220 )
221 .unwrap();
222 let qs = url.query().unwrap();
223 assert!(qs.contains("filter%5Bquery%5D=service%3Aapi"));
224 assert!(qs.contains("filter%5Bfrom%5D=2026-04-22T09%3A00%3A00Z"));
225 assert!(qs.contains("filter%5Bto%5D=2026-04-22T10%3A00%3A00Z"));
226 assert!(qs.contains("page%5Blimit%5D=50"));
227 assert!(!qs.contains("filter%5Bsources%5D"));
228 assert!(!qs.contains("filter%5Btags%5D"));
229 assert!(!qs.contains("page%5Bcursor%5D"));
230 }
231
232 #[test]
233 fn build_list_url_encodes_sources_and_tags() {
234 let filter = EventsListFilter {
235 query: None,
236 sources: Some("aws,kubernetes".into()),
237 tags: Some("env:prod,team:sre".into()),
238 };
239 let url = build_list_url(
240 "https://api.datadoghq.com",
241 &filter,
242 "2026-04-22T09:00:00Z",
243 "2026-04-22T10:00:00Z",
244 10,
245 None,
246 )
247 .unwrap();
248 let qs = url.query().unwrap();
249 assert!(qs.contains("filter%5Bsources%5D=aws%2Ckubernetes"));
250 assert!(qs.contains("filter%5Btags%5D=env%3Aprod%2Cteam%3Asre"));
251 }
252
253 #[test]
254 fn build_list_url_appends_cursor_when_provided() {
255 let url = build_list_url(
256 "https://api.datadoghq.com",
257 &EventsListFilter::default(),
258 "2026-04-22T09:00:00Z",
259 "2026-04-22T10:00:00Z",
260 10,
261 Some("tok-2"),
262 )
263 .unwrap();
264 let qs = url.query().unwrap();
265 assert!(qs.contains("page%5Bcursor%5D=tok-2"));
266 }
267
268 #[test]
269 fn build_list_url_rejects_invalid_base() {
270 let err = build_list_url(
271 "not a url",
272 &EventsListFilter::default(),
273 "2026-04-22T09:00:00Z",
274 "2026-04-22T10:00:00Z",
275 10,
276 None,
277 )
278 .unwrap_err();
279 assert!(err.to_string().contains("Invalid Datadog base URL"));
280 }
281
282 fn event_json(id: &str) -> serde_json::Value {
285 serde_json::json!({
286 "id": id,
287 "type": "event",
288 "attributes": {
289 "timestamp": "2026-04-22T10:00:00.000Z",
290 "title": "Deploy",
291 "source": "github",
292 "tags": ["env:prod"]
293 }
294 })
295 }
296
297 fn page_body(ids: &[&str], next_cursor: Option<&str>) -> serde_json::Value {
298 let data: Vec<serde_json::Value> = ids.iter().map(|id| event_json(id)).collect();
299 let meta = match next_cursor {
300 Some(c) => serde_json::json!({ "page": { "after": c }, "status": "done" }),
301 None => serde_json::json!({ "page": {}, "status": "done" }),
302 };
303 serde_json::json!({ "data": data, "meta": meta })
304 }
305
306 fn sample_body() -> serde_json::Value {
307 serde_json::json!({
308 "data": [event_json("EV1")],
309 "meta": {"page": {"after": "next"}, "status": "done"}
310 })
311 }
312
313 #[tokio::test]
316 async fn list_sends_filters_and_returns_parsed_response() {
317 let server = wiremock::MockServer::start().await;
318 wiremock::Mock::given(wiremock::matchers::method("GET"))
319 .and(wiremock::matchers::path("/api/v2/events"))
320 .and(wiremock::matchers::query_param(
321 "filter[query]",
322 "service:api",
323 ))
324 .and(wiremock::matchers::query_param(
325 "filter[from]",
326 "2026-04-22T09:00:00Z",
327 ))
328 .and(wiremock::matchers::query_param(
329 "filter[to]",
330 "2026-04-22T10:00:00Z",
331 ))
332 .and(wiremock::matchers::query_param("page[limit]", "10"))
333 .and(wiremock::matchers::header("DD-API-KEY", "api"))
334 .and(wiremock::matchers::header("DD-APPLICATION-KEY", "app"))
335 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(sample_body()))
336 .expect(1)
337 .mount(&server)
338 .await;
339
340 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
341 let result = EventsApi::new(&client)
342 .list(
343 &EventsListFilter {
344 query: Some("service:api".into()),
345 sources: None,
346 tags: None,
347 },
348 "2026-04-22T09:00:00Z",
349 "2026-04-22T10:00:00Z",
350 10,
351 None,
352 )
353 .await
354 .unwrap();
355 assert_eq!(result.data.len(), 1);
356 assert_eq!(result.data[0].id, "EV1");
357 }
358
359 #[tokio::test]
360 async fn list_includes_cursor_in_query_when_after_is_some() {
361 let server = wiremock::MockServer::start().await;
362 wiremock::Mock::given(wiremock::matchers::method("GET"))
363 .and(wiremock::matchers::path("/api/v2/events"))
364 .and(wiremock::matchers::query_param("page[cursor]", "tok-2"))
365 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(sample_body()))
366 .expect(1)
367 .mount(&server)
368 .await;
369
370 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
371 EventsApi::new(&client)
372 .list(
373 &EventsListFilter::default(),
374 "2026-04-22T09:00:00Z",
375 "2026-04-22T10:00:00Z",
376 10,
377 Some("tok-2"),
378 )
379 .await
380 .unwrap();
381 }
382
383 #[tokio::test]
386 async fn list_rejects_limit_above_max_page_limit_client_side() {
387 let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
388 let err = EventsApi::new(&client)
389 .list(
390 &EventsListFilter::default(),
391 "2026-04-22T09:00:00Z",
392 "2026-04-22T10:00:00Z",
393 MAX_PAGE_LIMIT + 1,
394 None,
395 )
396 .await
397 .unwrap_err();
398 assert!(err.to_string().contains("limit"));
399 assert!(err.to_string().contains(&MAX_PAGE_LIMIT.to_string()));
400 assert!(err.to_string().contains("list_all"));
401 }
402
403 #[tokio::test]
404 async fn list_propagates_api_errors() {
405 let server = wiremock::MockServer::start().await;
406 wiremock::Mock::given(wiremock::matchers::method("GET"))
407 .and(wiremock::matchers::path("/api/v2/events"))
408 .respond_with(
409 wiremock::ResponseTemplate::new(403).set_body_string(r#"{"errors":["nope"]}"#),
410 )
411 .mount(&server)
412 .await;
413
414 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
415 let err = EventsApi::new(&client)
416 .list(
417 &EventsListFilter::default(),
418 "2026-04-22T09:00:00Z",
419 "2026-04-22T10:00:00Z",
420 10,
421 None,
422 )
423 .await
424 .unwrap_err();
425 let msg = err.to_string();
426 assert!(msg.contains("403"));
427 assert!(msg.contains("nope"));
428 }
429
430 #[tokio::test]
431 async fn list_propagates_invalid_base_url_error() {
432 let client = DatadogClient::new("not a url", "api", "app").unwrap();
433 let err = EventsApi::new(&client)
434 .list(
435 &EventsListFilter::default(),
436 "2026-04-22T09:00:00Z",
437 "2026-04-22T10:00:00Z",
438 10,
439 None,
440 )
441 .await
442 .unwrap_err();
443 assert!(err.to_string().contains("Invalid Datadog base URL"));
444 }
445
446 #[tokio::test]
447 async fn list_propagates_network_errors() {
448 let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
449 let err = EventsApi::new(&client)
450 .list(
451 &EventsListFilter::default(),
452 "2026-04-22T09:00:00Z",
453 "2026-04-22T10:00:00Z",
454 10,
455 None,
456 )
457 .await
458 .unwrap_err();
459 assert!(err.to_string().contains("Failed to send"));
460 }
461
462 #[tokio::test]
463 async fn list_errors_on_malformed_response() {
464 let server = wiremock::MockServer::start().await;
465 wiremock::Mock::given(wiremock::matchers::method("GET"))
466 .and(wiremock::matchers::path("/api/v2/events"))
467 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
468 .mount(&server)
469 .await;
470
471 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
472 let err = EventsApi::new(&client)
473 .list(
474 &EventsListFilter::default(),
475 "2026-04-22T09:00:00Z",
476 "2026-04-22T10:00:00Z",
477 10,
478 None,
479 )
480 .await
481 .unwrap_err();
482 assert!(err.to_string().contains("Failed to parse"));
483 }
484
485 #[tokio::test]
488 async fn list_all_single_page_when_response_has_no_cursor() {
489 let server = wiremock::MockServer::start().await;
490 wiremock::Mock::given(wiremock::matchers::method("GET"))
491 .and(wiremock::matchers::path("/api/v2/events"))
492 .and(wiremock::matchers::query_param("page[limit]", "100"))
493 .respond_with(
494 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["a", "b"], None)),
495 )
496 .expect(1)
497 .mount(&server)
498 .await;
499
500 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
501 let result = EventsApi::new(&client)
502 .list_all(
503 &EventsListFilter::default(),
504 "2026-04-22T09:00:00Z",
505 "2026-04-22T10:00:00Z",
506 100,
507 )
508 .await
509 .unwrap();
510 assert_eq!(result.data.len(), 2);
511 }
512
513 #[tokio::test]
514 async fn list_all_follows_cursor_until_no_more_pages() {
515 let server = wiremock::MockServer::start().await;
519 let limit_str = MAX_PAGE_LIMIT.to_string();
520 wiremock::Mock::given(wiremock::matchers::method("GET"))
521 .and(wiremock::matchers::path("/api/v2/events"))
522 .and(wiremock::matchers::query_param(
523 "page[limit]",
524 limit_str.as_str(),
525 ))
526 .and(wiremock::matchers::query_param_is_missing("page[cursor]"))
527 .respond_with(
528 wiremock::ResponseTemplate::new(200)
529 .set_body_json(page_body(&["a", "b"], Some("c1"))),
530 )
531 .expect(1)
532 .mount(&server)
533 .await;
534 wiremock::Mock::given(wiremock::matchers::method("GET"))
535 .and(wiremock::matchers::path("/api/v2/events"))
536 .and(wiremock::matchers::query_param("page[cursor]", "c1"))
537 .respond_with(
538 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&["c"], None)),
539 )
540 .expect(1)
541 .mount(&server)
542 .await;
543
544 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
545 let result = EventsApi::new(&client)
546 .list_all(
547 &EventsListFilter::default(),
548 "2026-04-22T09:00:00Z",
549 "2026-04-22T10:00:00Z",
550 0,
551 )
552 .await
553 .unwrap();
554 let ids: Vec<&str> = result.data.iter().map(|e| e.id.as_str()).collect();
555 assert_eq!(ids, ["a", "b", "c"]);
556 assert!(result
557 .meta
558 .as_ref()
559 .and_then(|m| m.page.as_ref())
560 .and_then(|p| p.after.as_deref())
561 .is_none());
562 }
563
564 #[tokio::test]
565 async fn list_all_stops_at_explicit_limit_within_first_page() {
566 let server = wiremock::MockServer::start().await;
567 let ids = ["a", "b", "c"];
568 wiremock::Mock::given(wiremock::matchers::method("GET"))
569 .and(wiremock::matchers::path("/api/v2/events"))
570 .and(wiremock::matchers::query_param("page[limit]", "3"))
571 .respond_with(
572 wiremock::ResponseTemplate::new(200).set_body_json(page_body(&ids, Some("c1"))),
573 )
574 .expect(1)
575 .mount(&server)
576 .await;
577
578 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
579 let result = EventsApi::new(&client)
580 .list_all(
581 &EventsListFilter::default(),
582 "2026-04-22T09:00:00Z",
583 "2026-04-22T10:00:00Z",
584 3,
585 )
586 .await
587 .unwrap();
588 assert_eq!(result.data.len(), 3);
589 }
590
591 #[tokio::test]
592 async fn list_all_truncates_to_hard_cap_when_unbounded() {
593 let server = wiremock::MockServer::start().await;
594 let full_page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
595 .map(|i| event_json(&format!("e{i}")))
596 .collect();
597 let body = serde_json::json!({
598 "data": full_page,
599 "meta": { "page": { "after": "always-more" } }
600 });
601 wiremock::Mock::given(wiremock::matchers::method("GET"))
602 .and(wiremock::matchers::path("/api/v2/events"))
603 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
604 .mount(&server)
605 .await;
606
607 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
608 let result = EventsApi::new(&client)
609 .list_all(
610 &EventsListFilter::default(),
611 "2026-04-22T09:00:00Z",
612 "2026-04-22T10:00:00Z",
613 0,
614 )
615 .await
616 .unwrap();
617 assert_eq!(result.data.len(), HARD_CAP);
618 }
619
620 #[tokio::test]
621 async fn list_all_propagates_api_errors_on_first_page() {
622 let server = wiremock::MockServer::start().await;
623 wiremock::Mock::given(wiremock::matchers::method("GET"))
624 .and(wiremock::matchers::path("/api/v2/events"))
625 .respond_with(
626 wiremock::ResponseTemplate::new(403).set_body_string(r#"{"errors":["nope"]}"#),
627 )
628 .mount(&server)
629 .await;
630
631 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
632 let err = EventsApi::new(&client)
633 .list_all(
634 &EventsListFilter::default(),
635 "2026-04-22T09:00:00Z",
636 "2026-04-22T10:00:00Z",
637 0,
638 )
639 .await
640 .unwrap_err();
641 let msg = err.to_string();
642 assert!(msg.contains("403"));
643 assert!(msg.contains("nope"));
644 }
645
646 #[tokio::test]
647 async fn list_all_caps_explicit_limit_at_hard_cap() {
648 let server = wiremock::MockServer::start().await;
649 let full_page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
650 .map(|i| event_json(&format!("e{i}")))
651 .collect();
652 let body = serde_json::json!({
653 "data": full_page,
654 "meta": { "page": { "after": "always-more" } }
655 });
656 wiremock::Mock::given(wiremock::matchers::method("GET"))
657 .and(wiremock::matchers::path("/api/v2/events"))
658 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
659 .mount(&server)
660 .await;
661
662 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
663 let result = EventsApi::new(&client)
664 .list_all(
665 &EventsListFilter::default(),
666 "2026-04-22T09:00:00Z",
667 "2026-04-22T10:00:00Z",
668 HARD_CAP + 50,
669 )
670 .await
671 .unwrap();
672 assert_eq!(result.data.len(), HARD_CAP);
673 }
674}