1use 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
30const TYPE_COUNT: u8 = 1;
32const TYPE_GAUGE: u8 = 3;
33
34#[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
48pub 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
68pub 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
93pub 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); }
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
131const 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
161fn 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 #[test]
289 fn baseline_then_deltas_then_tags() {
290 *BASELINE.lock().unwrap() = None;
291
292 assert!(build_series(1000).is_empty());
294
295 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 assert!(
312 v >= 500.0,
313 "delta should include the 500 just recorded: {v}"
314 );
315 assert_eq!(saved["type"], TYPE_COUNT);
316
317 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 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}