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(|| 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
91pub 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); }
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
129const 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
159fn 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 #[test]
287 fn baseline_then_deltas_then_tags() {
288 *BASELINE.lock().unwrap() = None;
289
290 assert!(build_series(1000).is_empty());
292
293 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 assert!(
310 v >= 500.0,
311 "delta should include the 500 just recorded: {v}"
312 );
313 assert_eq!(saved["type"], TYPE_COUNT);
314
315 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 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}