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