Skip to main content

lean_ctx/core/
datadog_push.rs

1//! Agentless Datadog export (GL #401, setup path B).
2//!
3//! Pushes the metrics-contract series straight to the Datadog Metrics API v2
4//! (`POST /api/v2/series`, `DD-API-KEY` header) — no local Agent, no OTel
5//! Collector. Strictly opt-in: **both** `LEAN_CTX_DATADOG_PUSH=1` and
6//! `DD_API_KEY` must be set; a stray `DD_API_KEY` from another tool never
7//! turns on egress by itself.
8//!
9//! Counter semantics: Datadog v2 `count` points are per-interval deltas, not
10//! cumulative totals. The pusher keeps the last pushed totals in-process and
11//! submits deltas; the first cycle only records the baseline (submitting a
12//! lifetime total as one interval would spike every graph). Gauges go out on
13//! every cycle, including the first.
14//!
15//! Tag policy is identical to the `lean_ctx_info` series: five bounded tags
16//! (`project`, `profile`, `agent_role`, `model`, `version`) attached to every
17//! series — bounded values, so Datadog custom-metric cardinality stays flat.
18
19use std::sync::Mutex;
20use std::time::Duration;
21
22const ENABLE_ENV: &str = "LEAN_CTX_DATADOG_PUSH";
23const API_KEY_ENV: &str = "DD_API_KEY";
24const SITE_ENV: &str = "DD_SITE";
25const INTERVAL_ENV: &str = "LEAN_CTX_DATADOG_INTERVAL_SECS";
26
27const DEFAULT_INTERVAL_SECS: u64 = 60;
28const MIN_INTERVAL_SECS: u64 = 10;
29
30/// Datadog v2 metric intake types.
31const TYPE_COUNT: u8 = 1;
32const TYPE_GAUGE: u8 = 3;
33
34/// Cumulative totals as of the previous push — source of the count deltas.
35#[derive(Default, Clone, Copy)]
36struct Baseline {
37    tokens_in: u64,
38    tokens_out: u64,
39    tokens_saved: u64,
40    ledger_tokens: u64,
41    ledger_usd: f64,
42    tool_calls: u64,
43    tool_errors: u64,
44}
45
46static BASELINE: Mutex<Option<Baseline>> = Mutex::new(None);
47
48/// True when the operator explicitly enabled the push exporter.
49pub fn enabled() -> bool {
50    std::env::var(ENABLE_ENV).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
51        && std::env::var(API_KEY_ENV).is_ok_and(|v| !v.trim().is_empty())
52}
53
54fn interval() -> Duration {
55    let secs = std::env::var(INTERVAL_ENV)
56        .ok()
57        .and_then(|v| v.parse::<u64>().ok())
58        .unwrap_or(DEFAULT_INTERVAL_SECS)
59        .max(MIN_INTERVAL_SECS);
60    Duration::from_secs(secs)
61}
62
63fn intake_url() -> String {
64    let site = std::env::var(SITE_ENV).unwrap_or_else(|_| "datadoghq.com".to_string());
65    format!("https://api.{site}/api/v2/series")
66}
67
68/// Spawn the background push loop if (and only if) the operator opted in.
69/// Called from long-running entry points (dashboard server). Returns whether
70/// the loop was started.
71pub fn spawn_if_enabled() -> bool {
72    if !enabled() {
73        return false;
74    }
75    std::thread::Builder::new()
76        .name("dd-push".into())
77        .spawn(|| {
78            loop {
79                match push_once() {
80                    Ok(sent) => {
81                        tracing::debug!("datadog push: {sent} series sent");
82                    }
83                    Err(e) => {
84                        tracing::warn!("datadog push failed (will retry): {e}");
85                    }
86                }
87                std::thread::sleep(interval());
88            }
89        })
90        .is_ok()
91}
92
93/// Build and submit one batch. Returns the number of series sent.
94pub fn push_once() -> Result<usize, String> {
95    let api_key = std::env::var(API_KEY_ENV).map_err(|_| "DD_API_KEY not set".to_string())?;
96    let series = build_series(now_ts());
97    if series.is_empty() {
98        return Ok(0); // first cycle: baseline recorded, gauges follow next tick
99    }
100    let count = series.len();
101    let payload = serde_json::json!({ "series": series });
102    let body = serde_json::to_vec(&payload).map_err(|e| e.to_string())?;
103
104    let agent = crate::core::http_client::ureq_agent(
105        ureq::config::Config::builder()
106            .tls_config(crate::core::http_client::platform_tls_config())
107            .timeout_global(Some(Duration::from_secs(10)))
108            .http_status_as_error(false)
109            .build(),
110    );
111    let resp = agent
112        .post(&intake_url())
113        .header("Content-Type", "application/json")
114        .header("DD-API-KEY", api_key.trim())
115        .send(body.as_slice())
116        .map_err(|e| format!("datadog intake unreachable: {e}"))?;
117
118    let status = resp.status().as_u16();
119    if !(200..300).contains(&status) {
120        let body = resp.into_body().read_to_string().unwrap_or_default();
121        return Err(format!("datadog intake rejected ({status}): {body}"));
122    }
123    Ok(count)
124}
125
126fn now_ts() -> i64 {
127    std::time::SystemTime::now()
128        .duration_since(std::time::UNIX_EPOCH)
129        .map_or(0, |d| d.as_secs() as i64)
130}
131
132/// Extra static tags, e.g. `env:prod,team:platform` — the Datadog-side
133/// equivalent of OTel resource attributes like `deployment.environment`.
134const EXTRA_TAGS_ENV: &str = "LEAN_CTX_DD_TAGS";
135
136fn tags() -> Vec<String> {
137    let mut tags: Vec<String> = super::telemetry::info_tags()
138        .into_iter()
139        .map(|(k, v)| format!("{k}:{v}"))
140        .collect();
141    if let Ok(extra) = std::env::var(EXTRA_TAGS_ENV) {
142        tags.extend(
143            extra
144                .split(',')
145                .map(str::trim)
146                .filter(|t| !t.is_empty() && t.contains(':'))
147                .map(String::from),
148        );
149    }
150    tags
151}
152
153fn series_entry(metric: &str, ty: u8, value: f64, ts: i64, tags: &[String]) -> serde_json::Value {
154    serde_json::json!({
155        "metric": metric,
156        "type": ty,
157        "points": [{ "timestamp": ts, "value": value }],
158        "tags": tags,
159    })
160}
161
162/// Assemble the batch: gauges always, counts as deltas vs. the baseline.
163/// First call returns an empty batch (baseline only) by design.
164fn build_series(ts: i64) -> Vec<serde_json::Value> {
165    let snap = super::telemetry::global_metrics().snapshot();
166    let (ledger_tokens, ledger_usd) = super::telemetry::ledger_totals_cached();
167    let current = Baseline {
168        tokens_in: snap.tokens_input,
169        tokens_out: snap.tokens_output,
170        tokens_saved: snap.tokens_saved,
171        ledger_tokens,
172        ledger_usd,
173        tool_calls: snap.tool_calls_total,
174        tool_errors: snap.tool_calls_error,
175    };
176
177    let Ok(mut guard) = BASELINE.lock() else {
178        return Vec::new();
179    };
180    let Some(prev) = *guard else {
181        *guard = Some(current);
182        return Vec::new();
183    };
184    *guard = Some(current);
185    drop(guard);
186
187    let t = tags();
188    let d = |cur: u64, old: u64| cur.saturating_sub(old) as f64;
189    let mut series = vec![
190        series_entry(
191            "leanctx.tokens.in",
192            TYPE_COUNT,
193            d(current.tokens_in, prev.tokens_in),
194            ts,
195            &t,
196        ),
197        series_entry(
198            "leanctx.tokens.out",
199            TYPE_COUNT,
200            d(current.tokens_out, prev.tokens_out),
201            ts,
202            &t,
203        ),
204        series_entry(
205            "leanctx.tokens.saved",
206            TYPE_COUNT,
207            d(current.tokens_saved, prev.tokens_saved),
208            ts,
209            &t,
210        ),
211        series_entry(
212            "leanctx.tokens.saved_verified",
213            TYPE_COUNT,
214            d(current.ledger_tokens, prev.ledger_tokens),
215            ts,
216            &t,
217        ),
218        series_entry(
219            "leanctx.cost.saved_usd",
220            TYPE_COUNT,
221            (current.ledger_usd - prev.ledger_usd).max(0.0),
222            ts,
223            &t,
224        ),
225        series_entry(
226            "leanctx.tools.calls",
227            TYPE_COUNT,
228            d(current.tool_calls, prev.tool_calls),
229            ts,
230            &t,
231        ),
232        series_entry(
233            "leanctx.tools.errors",
234            TYPE_COUNT,
235            d(current.tool_errors, prev.tool_errors),
236            ts,
237            &t,
238        ),
239    ];
240
241    let slo = crate::core::slo::evaluate_quiet();
242    let verify = crate::core::output_verification::stats_snapshot();
243    series.extend([
244        series_entry(
245            "leanctx.cache.hit_ratio",
246            TYPE_GAUGE,
247            snap.cache_hit_rate,
248            ts,
249            &t,
250        ),
251        series_entry(
252            "leanctx.compression.ratio",
253            TYPE_GAUGE,
254            snap.compression_ratio,
255            ts,
256            &t,
257        ),
258        series_entry(
259            "leanctx.session.uptime_seconds",
260            TYPE_GAUGE,
261            snap.session_uptime_secs as f64,
262            ts,
263            &t,
264        ),
265        series_entry(
266            "leanctx.slo.violations",
267            TYPE_GAUGE,
268            slo.violations.len() as f64,
269            ts,
270            &t,
271        ),
272        series_entry(
273            "leanctx.verification.pass_ratio",
274            TYPE_GAUGE,
275            verify.pass_rate,
276            ts,
277            &t,
278        ),
279    ]);
280    series
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    /// Single sequential test: `BASELINE` is process-global, so the
288    /// baseline → delta → tags assertions must not run as parallel tests.
289    #[test]
290    fn baseline_then_deltas_then_tags() {
291        *BASELINE
292            .lock()
293            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
294
295        // Cycle 1: baseline only, nothing submitted.
296        assert!(build_series(1000).is_empty());
297
298        // Cycle 2: full batch with counts as deltas, not lifetime totals.
299        let m = super::super::telemetry::global_metrics();
300        m.record_tokens(100, 10, 500);
301        let batch = build_series(1060);
302        assert!(
303            batch.len() >= 12,
304            "expected full batch, got {}",
305            batch.len()
306        );
307        let saved = batch
308            .iter()
309            .find(|s| s["metric"] == "leanctx.tokens.saved")
310            .expect("tokens.saved present");
311        let v = saved["points"][0]["value"].as_f64().unwrap();
312        // Other lib tests may record tokens concurrently (global metrics), so
313        // ≥ the 500 just recorded — but never absent or typed as gauge.
314        assert!(
315            v >= 500.0,
316            "delta should include the 500 just recorded: {v}"
317        );
318        assert_eq!(saved["type"], TYPE_COUNT);
319
320        // Every series carries the five bounded info tags.
321        for s in &batch {
322            let tags: Vec<String> = s["tags"]
323                .as_array()
324                .unwrap()
325                .iter()
326                .map(|t| t.as_str().unwrap().to_string())
327                .collect();
328            for key in ["project:", "profile:", "agent_role:", "model:", "version:"] {
329                assert!(
330                    tags.iter().any(|t| t.starts_with(key)),
331                    "{} missing tag {key}",
332                    s["metric"]
333                );
334            }
335        }
336    }
337
338    #[test]
339    fn disabled_without_explicit_opt_in() {
340        // Neither env set in the test environment → off.
341        assert!(!enabled());
342    }
343
344    #[test]
345    fn site_routing_defaults_to_us() {
346        assert_eq!(intake_url(), "https://api.datadoghq.com/api/v2/series");
347    }
348}