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 = 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
132const 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
162fn 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 #[test]
290 fn baseline_then_deltas_then_tags() {
291 *BASELINE
292 .lock()
293 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
294
295 assert!(build_series(1000).is_empty());
297
298 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 assert!(
315 v >= 500.0,
316 "delta should include the 500 just recorded: {v}"
317 );
318 assert_eq!(saved["type"], TYPE_COUNT);
319
320 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 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}