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 = ureq::Agent::new_with_config(
105        ureq::config::Config::builder()
106            .timeout_global(Some(Duration::from_secs(10)))
107            .http_status_as_error(false)
108            .build(),
109    );
110    let resp = agent
111        .post(&intake_url())
112        .header("Content-Type", "application/json")
113        .header("DD-API-KEY", api_key.trim())
114        .send(body.as_slice())
115        .map_err(|e| format!("datadog intake unreachable: {e}"))?;
116
117    let status = resp.status().as_u16();
118    if !(200..300).contains(&status) {
119        let body = resp.into_body().read_to_string().unwrap_or_default();
120        return Err(format!("datadog intake rejected ({status}): {body}"));
121    }
122    Ok(count)
123}
124
125fn now_ts() -> i64 {
126    std::time::SystemTime::now()
127        .duration_since(std::time::UNIX_EPOCH)
128        .map_or(0, |d| d.as_secs() as i64)
129}
130
131/// Extra static tags, e.g. `env:prod,team:platform` — the Datadog-side
132/// equivalent of OTel resource attributes like `deployment.environment`.
133const EXTRA_TAGS_ENV: &str = "LEAN_CTX_DD_TAGS";
134
135fn tags() -> Vec<String> {
136    let mut tags: Vec<String> = super::telemetry::info_tags()
137        .into_iter()
138        .map(|(k, v)| format!("{k}:{v}"))
139        .collect();
140    if let Ok(extra) = std::env::var(EXTRA_TAGS_ENV) {
141        tags.extend(
142            extra
143                .split(',')
144                .map(str::trim)
145                .filter(|t| !t.is_empty() && t.contains(':'))
146                .map(String::from),
147        );
148    }
149    tags
150}
151
152fn series_entry(metric: &str, ty: u8, value: f64, ts: i64, tags: &[String]) -> serde_json::Value {
153    serde_json::json!({
154        "metric": metric,
155        "type": ty,
156        "points": [{ "timestamp": ts, "value": value }],
157        "tags": tags,
158    })
159}
160
161/// Assemble the batch: gauges always, counts as deltas vs. the baseline.
162/// First call returns an empty batch (baseline only) by design.
163fn build_series(ts: i64) -> Vec<serde_json::Value> {
164    let snap = super::telemetry::global_metrics().snapshot();
165    let (ledger_tokens, ledger_usd) = super::telemetry::ledger_totals_cached();
166    let current = Baseline {
167        tokens_in: snap.tokens_input,
168        tokens_out: snap.tokens_output,
169        tokens_saved: snap.tokens_saved,
170        ledger_tokens,
171        ledger_usd,
172        tool_calls: snap.tool_calls_total,
173        tool_errors: snap.tool_calls_error,
174    };
175
176    let Ok(mut guard) = BASELINE.lock() else {
177        return Vec::new();
178    };
179    let Some(prev) = *guard else {
180        *guard = Some(current);
181        return Vec::new();
182    };
183    *guard = Some(current);
184    drop(guard);
185
186    let t = tags();
187    let d = |cur: u64, old: u64| cur.saturating_sub(old) as f64;
188    let mut series = vec![
189        series_entry(
190            "leanctx.tokens.in",
191            TYPE_COUNT,
192            d(current.tokens_in, prev.tokens_in),
193            ts,
194            &t,
195        ),
196        series_entry(
197            "leanctx.tokens.out",
198            TYPE_COUNT,
199            d(current.tokens_out, prev.tokens_out),
200            ts,
201            &t,
202        ),
203        series_entry(
204            "leanctx.tokens.saved",
205            TYPE_COUNT,
206            d(current.tokens_saved, prev.tokens_saved),
207            ts,
208            &t,
209        ),
210        series_entry(
211            "leanctx.tokens.saved_verified",
212            TYPE_COUNT,
213            d(current.ledger_tokens, prev.ledger_tokens),
214            ts,
215            &t,
216        ),
217        series_entry(
218            "leanctx.cost.saved_usd",
219            TYPE_COUNT,
220            (current.ledger_usd - prev.ledger_usd).max(0.0),
221            ts,
222            &t,
223        ),
224        series_entry(
225            "leanctx.tools.calls",
226            TYPE_COUNT,
227            d(current.tool_calls, prev.tool_calls),
228            ts,
229            &t,
230        ),
231        series_entry(
232            "leanctx.tools.errors",
233            TYPE_COUNT,
234            d(current.tool_errors, prev.tool_errors),
235            ts,
236            &t,
237        ),
238    ];
239
240    let slo = crate::core::slo::evaluate_quiet();
241    let verify = crate::core::output_verification::stats_snapshot();
242    series.extend([
243        series_entry(
244            "leanctx.cache.hit_ratio",
245            TYPE_GAUGE,
246            snap.cache_hit_rate,
247            ts,
248            &t,
249        ),
250        series_entry(
251            "leanctx.compression.ratio",
252            TYPE_GAUGE,
253            snap.compression_ratio,
254            ts,
255            &t,
256        ),
257        series_entry(
258            "leanctx.session.uptime_seconds",
259            TYPE_GAUGE,
260            snap.session_uptime_secs as f64,
261            ts,
262            &t,
263        ),
264        series_entry(
265            "leanctx.slo.violations",
266            TYPE_GAUGE,
267            slo.violations.len() as f64,
268            ts,
269            &t,
270        ),
271        series_entry(
272            "leanctx.verification.pass_ratio",
273            TYPE_GAUGE,
274            verify.pass_rate,
275            ts,
276            &t,
277        ),
278    ]);
279    series
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    /// Single sequential test: `BASELINE` is process-global, so the
287    /// baseline → delta → tags assertions must not run as parallel tests.
288    #[test]
289    fn baseline_then_deltas_then_tags() {
290        *BASELINE.lock().unwrap() = None;
291
292        // Cycle 1: baseline only, nothing submitted.
293        assert!(build_series(1000).is_empty());
294
295        // Cycle 2: full batch with counts as deltas, not lifetime totals.
296        let m = super::super::telemetry::global_metrics();
297        m.record_tokens(100, 10, 500);
298        let batch = build_series(1060);
299        assert!(
300            batch.len() >= 12,
301            "expected full batch, got {}",
302            batch.len()
303        );
304        let saved = batch
305            .iter()
306            .find(|s| s["metric"] == "leanctx.tokens.saved")
307            .expect("tokens.saved present");
308        let v = saved["points"][0]["value"].as_f64().unwrap();
309        // Other lib tests may record tokens concurrently (global metrics), so
310        // ≥ the 500 just recorded — but never absent or typed as gauge.
311        assert!(
312            v >= 500.0,
313            "delta should include the 500 just recorded: {v}"
314        );
315        assert_eq!(saved["type"], TYPE_COUNT);
316
317        // Every series carries the five bounded info tags.
318        for s in &batch {
319            let tags: Vec<String> = s["tags"]
320                .as_array()
321                .unwrap()
322                .iter()
323                .map(|t| t.as_str().unwrap().to_string())
324                .collect();
325            for key in ["project:", "profile:", "agent_role:", "model:", "version:"] {
326                assert!(
327                    tags.iter().any(|t| t.starts_with(key)),
328                    "{} missing tag {key}",
329                    s["metric"]
330                );
331            }
332        }
333    }
334
335    #[test]
336    fn disabled_without_explicit_opt_in() {
337        // Neither env set in the test environment → off.
338        assert!(!enabled());
339    }
340
341    #[test]
342    fn site_routing_defaults_to_us() {
343        assert_eq!(intake_url(), "https://api.datadoghq.com/api/v2/series");
344    }
345}