unfault 1.0.4

Unfault — a cognitive context engine for thoughtful engineers
Documentation
//! Datadog SLO provider.

use std::env;

use anyhow::{Context, Result};
use reqwest::Client;
use serde::Deserialize;

use crate::slo::types::{SloDefinition, SloProviderKind};

/// Datadog SLO provider.
pub struct DatadogSloProvider {
    api_key: String,
    app_key: String,
    site: String,
}

impl DatadogSloProvider {
    /// Check if Datadog credentials are available.
    pub fn is_available() -> bool {
        env::var("DD_API_KEY").is_ok() && env::var("DD_APP_KEY").is_ok()
    }

    /// Create a provider from environment variables.
    ///
    /// Returns `None` if credentials are not available.
    pub fn from_env() -> Option<Self> {
        let api_key = env::var("DD_API_KEY").ok()?;
        let app_key = env::var("DD_APP_KEY").ok()?;
        let site = env::var("DD_SITE").unwrap_or_else(|_| "datadoghq.com".to_string());
        Some(Self {
            api_key,
            app_key,
            site,
        })
    }

    /// Fetch all SLOs from Datadog.
    pub async fn fetch_slos(&self, client: &Client) -> Result<Vec<SloDefinition>> {
        let url = format!("https://api.{}/api/v1/slo", self.site);

        let resp = client
            .get(&url)
            .header("DD-API-KEY", &self.api_key)
            .header("DD-APPLICATION-KEY", &self.app_key)
            .header("Accept", "application/json")
            .send()
            .await
            .context("Failed to send request to Datadog API")?;

        if !resp.status().is_success() {
            let status = resp.status();
            let body = resp.text().await.unwrap_or_default();
            anyhow::bail!("Datadog API error: {} — {}", status, body);
        }

        let response: DatadogSloListResponse = resp
            .json()
            .await
            .context("Failed to parse Datadog SLO response")?;

        Ok(response
            .data
            .into_iter()
            .map(|s| self.convert_slo(s))
            .collect())
    }

    fn convert_slo(&self, slo: DatadogSlo) -> SloDefinition {
        let path_pattern = slo.tags.iter().find_map(|tag| {
            tag.strip_prefix("path:")
                .or_else(|| tag.strip_prefix("endpoint:"))
                .map(|p| p.to_string())
        });

        let http_method = slo
            .tags
            .iter()
            .find_map(|tag| tag.strip_prefix("method:").map(|m| m.to_uppercase()));

        let primary_threshold = slo
            .thresholds
            .iter()
            .find(|t| t.timeframe == "30d")
            .or(slo.thresholds.first());

        let (target_percent, timeframe) = primary_threshold
            .map(|t| (t.target, t.timeframe.clone()))
            .unwrap_or((99.0, "30d".to_string()));

        let current_percent = slo.overall_status.as_ref().map(|s| s.sli_value);
        let error_budget_remaining = slo
            .overall_status
            .as_ref()
            .and_then(|s| s.error_budget_remaining);

        let dashboard_url = Some(format!("https://app.{}/slo?slo_id={}", self.site, slo.id));
        SloDefinition {
            id: slo.id,
            name: slo.name,
            provider: SloProviderKind::Datadog,
            path_pattern,
            http_method,
            target_percent,
            current_percent,
            error_budget_remaining,
            timeframe,
            dashboard_url,
        }
    }
}

// ── Wire types ────────────────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
struct DatadogSloListResponse {
    data: Vec<DatadogSlo>,
}

#[derive(Debug, Deserialize)]
struct DatadogSlo {
    id: String,
    name: String,
    #[serde(default)]
    tags: Vec<String>,
    #[serde(default)]
    thresholds: Vec<DatadogThreshold>,
    overall_status: Option<DatadogOverallStatus>,
}

#[derive(Debug, Deserialize)]
struct DatadogThreshold {
    target: f64,
    timeframe: String,
}

#[derive(Debug, Deserialize)]
struct DatadogOverallStatus {
    sli_value: f64,
    error_budget_remaining: Option<f64>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn is_available_checks_both_keys() {
        let has_api_key = env::var("DD_API_KEY").is_ok();
        let has_app_key = env::var("DD_APP_KEY").is_ok();
        assert_eq!(
            DatadogSloProvider::is_available(),
            has_api_key && has_app_key
        );
    }
}