1const COMMANDS: &[(&str, &[&str])] = &[
15 ("badge", &[]),
16 ("shell", &["--shell"]),
17 ("gain", &[]),
18 ("spend", &[]),
19 ("savings", &[]),
20 ("learning", &[]),
21 ("autopilot", &[]),
22 ("conformance", &["selftest"]),
23 ("health", &[]),
24 ("billing", &[]),
25 ("finops", &[]),
26 ("roi", &[]),
27 ("output_savings", &["output-savings"]),
28 ("value_report", &["value-report"]),
29 ("evidence_export", &["evidence-export"]),
30 ("evidence", &[]),
31 ("shadow", &[]),
32 ("triage", &[]),
33 ("scenario", &[]),
34 ("token_report", &["token-report", "report-tokens"]),
35 ("pack", &[]),
36 ("migrate", &[]),
37 ("policy", &[]),
38 ("addon", &["addons"]),
39 ("plugin", &["plugins"]),
40 ("embeddings", &[]),
41 ("model", &[]),
42 ("gpu", &["enable-gpu"]),
43 ("rules", &[]),
44 ("proof", &[]),
45 ("value", &[]),
46 ("claude_mod", &["claude-mod"]),
47 ("inspect", &[]),
48 ("snapshot", &[]),
49 ("verify", &[]),
50 ("verify_cache", &["verify-cache", "cache-selftest"]),
51 ("visualize", &[]),
52 ("audit", &[]),
53 ("compliance", &[]),
54 ("agent", &[]),
55 ("instructions", &[]),
56 ("index", &[]),
57 ("semantic_search", &["semantic-search", "search-code"]),
58 ("explore", &[]),
59 ("repomap", &["repo-map"]),
60 ("cep", &[]),
61 ("demo", &[]),
62 ("dashboard", &["cockpit"]),
65 ("team", &[]),
66 ("provider", &[]),
67 ("serve", &[]),
68 ("watch", &[]),
69 ("proxy", &[]),
70 ("daemon", &[]),
71 ("init", &[]),
72 ("setup", &[]),
73 ("onboard", &[]),
74 ("install", &[]),
75 ("bootstrap", &[]),
76 ("wrap", &[]),
77 ("unwrap", &[]),
78 ("status", &[]),
79 ("cognitive", &[]),
80 ("deps", &[]),
81 ("discover", &[]),
82 ("engine", &[]),
83 ("ghost", &[]),
84 ("filter", &[]),
85 ("heatmap", &[]),
86 ("graph", &[]),
87 ("smells", &[]),
88 ("session", &[]),
89 ("ledger", &[]),
90 ("ocla", &[]),
91 ("control", &["context-control"]),
92 ("plan", &["context-plan"]),
93 ("compile", &["context-compile"]),
94 ("import", &[]),
95 ("checkpoints", &[]),
96 ("knowledge", &[]),
97 ("kit", &["kits"]),
98 ("skillify", &[]),
99 ("summary", &[]),
100 ("overview", &[]),
101 ("compress", &[]),
102 ("wrapped", &[]),
103 ("sessions", &["session-store"]),
104 ("benchmark", &[]),
105 ("benchmark_run", &["benchmark-run", "bench-run"]),
106 ("calibrate", &[]),
107 ("compact", &[]),
108 ("profile", &[]),
109 ("tools", &[]),
110 ("config", &[]),
111 ("allow", &[]),
112 ("allow_path", &["allow-path"]),
113 ("security", &[]),
114 ("yolo", &[]),
115 ("secure", &["lockdown"]),
116 ("trust", &[]),
117 ("untrust", &[]),
118 ("stats", &[]),
119 ("introspect", &[]),
120 ("cache", &[]),
121 ("theme", &[]),
122 ("enterprise", &[]),
123 ("tee", &[]),
124 ("terse", &["compression"]),
125 ("slow_log", &["slow-log"]),
126 ("debug_log", &["debug-log"]),
127 ("editor_bridge", &["editor-bridge"]),
128 ("update", &["--self-update"]),
129 ("restart", &[]),
130 ("stop", &[]),
131 ("doctor", &[]),
132 ("harden", &[]),
133 ("export_rules", &["export-rules"]),
134 ("gotchas", &["bugs"]),
135 ("learn", &[]),
136 ("report_issue", &["report-issue", "report"]),
137 ("uninstall", &[]),
138 ("safety_levels", &["safety-levels", "safety"]),
139 ("cheat", &["cheatsheet", "cheat-sheet"]),
140 ("login", &[]),
141 ("register", &[]),
142 ("sync", &[]),
143 ("contribute", &[]),
144 ("telemetry", &[]),
145 ("cloud", &[]),
146 ("upgrade", &[]),
147];
148
149const VERBS: &[&str] = &[
152 "add",
153 "apply",
154 "auto-load",
155 "build",
156 "build-full",
157 "build-semantic",
158 "check",
159 "checkpoint-inspect",
160 "checkpoint-seal",
161 "clean",
162 "clear",
163 "create",
164 "delete",
165 "delete-remote",
166 "diff",
167 "disable",
168 "doctor",
169 "enable",
170 "explain",
171 "export",
172 "export-html",
173 "gc",
174 "get",
175 "impact",
176 "import",
177 "info",
178 "init",
179 "inspect",
180 "install",
181 "list",
182 "login",
183 "logout",
184 "ls",
185 "migrate",
186 "neighbors",
187 "off",
188 "on",
189 "parity",
190 "path",
191 "prune",
192 "publish",
193 "pull",
194 "push",
195 "rebuild",
196 "receive",
197 "related",
198 "remove",
199 "reset",
200 "restart",
201 "rm",
202 "rollback",
203 "run",
204 "search",
205 "send",
206 "set",
207 "show",
208 "snapshot",
209 "start",
210 "status",
211 "stop",
212 "symbol",
213 "uninstall",
214 "update",
215 "verify",
216 "warm",
217 "watch",
218];
219
220const NOT_COUNTED: &[&str] = &[
223 "-c",
224 "exec",
225 "-t",
226 "--track",
227 "hook",
228 "__complete",
229 "completions",
230 "mcp",
231 "read",
232 "call",
233 "diff",
234 "grep",
235 "glob",
236 "find",
237 "ls",
238 "raw",
239 "bypass",
240 "statusline",
241 "editor-signal",
242 "editor-session",
243 "prompt-segment",
244 "git-trailer",
245 "help",
246 "--help",
247 "-h",
248 "codex-protected",
249 "dev-install",
250 "codesign-setup",
251 "forgot-password",
252];
253
254fn segment(word: &str) -> String {
255 word.trim_start_matches('-').replace('-', "_")
256}
257
258#[must_use]
261pub fn cli_code(args: &[String]) -> Option<String> {
262 let first = args.get(1)?.as_str();
263 if NOT_COUNTED.contains(&first) || args.iter().skip(2).any(|a| a == "--dry-run") {
264 return None;
265 }
266 let (command, _) = COMMANDS.iter().find(|(name, aliases)| {
267 *name == first || segment(first) == *name || aliases.contains(&first)
268 })?;
269 let verb = args
270 .get(2)
271 .map(String::as_str)
272 .filter(|word| VERBS.contains(word))
273 .map(segment);
274 let code = match verb {
275 Some(verb) => format!("cli.{command}.{verb}"),
276 None => format!("cli.{command}"),
277 };
278 crate::core::telemetry_v2::valid_feature_code(&code).then_some(code)
279}
280
281pub fn record_cli(args: &[String]) {
283 if let Some(code) = cli_code(args) {
284 let _ = crate::core::telemetry_aggregate::record_feature(&code, true);
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum Feature {
291 GraphIndex,
292 Bm25Index,
293 SemanticIndex,
294}
295
296impl Feature {
297 #[must_use]
298 pub fn code(self) -> &'static str {
299 match self {
300 Self::GraphIndex => "index.graph",
301 Self::Bm25Index => "index.bm25",
302 Self::SemanticIndex => "index.semantic",
303 }
304 }
305}
306
307pub fn record(feature: Feature, ok: bool) {
309 let _ = crate::core::telemetry_aggregate::record_feature(feature.code(), ok);
310}
311
312const MCP_SESSION: &str = "mcp.session";
314const MCP_SESSION_EMPTY: &str = "mcp.session.empty";
319const MCP_SESSION_CLEAN: &str = "mcp.session.clean";
322const MCP_SESSION_RECOVERED: &str = "mcp.session.recovered";
324const MCP_SESSION_UNRESOLVED: &str = "mcp.session.unresolved";
327const MCP_SESSION_RETRY: &str = "mcp.session.retry";
330
331pub fn record_mcp_session_start() {
333 let _ = crate::core::telemetry_aggregate::record_feature(MCP_SESSION, true);
334}
335
336fn leanctx_side(kind: crate::core::telemetry_failure::FailureKind) -> bool {
341 use crate::core::telemetry_failure::FailureKind;
342 matches!(
343 kind,
344 FailureKind::Timeout
345 | FailureKind::Unavailable
346 | FailureKind::TooLarge
347 | FailureKind::Other
348 )
349}
350
351fn mcp_session_end_tallies(
353 outcome: &crate::core::telemetry::SessionOutcome,
354) -> Vec<(&'static str, u64, u64)> {
355 if outcome.calls == 0 {
356 return vec![(MCP_SESSION_EMPTY, 1, 0)];
357 }
358 let ending = if outcome.failures == 0 {
359 (MCP_SESSION_CLEAN, 1, 0)
360 } else if outcome.unresolved.is_empty() {
361 (MCP_SESSION_RECOVERED, 1, 0)
362 } else {
363 let ours = outcome.unresolved.iter().any(|kind| leanctx_side(*kind));
364 (MCP_SESSION_UNRESOLVED, 1, u64::from(ours))
365 };
366 vec![
367 ending,
368 (MCP_SESSION_RETRY, outcome.retries, outcome.retry_failures),
369 ]
370}
371
372pub fn record_mcp_session_end() {
376 static ENDED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
377 if ENDED.swap(true, std::sync::atomic::Ordering::SeqCst) {
378 return;
379 }
380 let outcome = crate::core::telemetry::global_metrics().session_outcome();
381 let _ =
382 crate::core::telemetry_aggregate::record_feature_counts(&mcp_session_end_tallies(&outcome));
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn args(words: &[&str]) -> Vec<String> {
390 std::iter::once("lean-ctx")
391 .chain(words.iter().copied())
392 .map(str::to_string)
393 .collect()
394 }
395
396 #[test]
397 fn dry_runs_are_not_counted() {
398 for words in [
399 &["setup", "--dry-run"][..],
400 &["onboard", "--agents", "cursor", "--dry-run"][..],
401 &["update", "--dry-run"][..],
402 &["uninstall", "--dry-run"][..],
403 ] {
404 assert_eq!(cli_code(&args(words)), None, "{words:?}");
405 }
406 assert_eq!(cli_code(&args(&["setup"])).as_deref(), Some("cli.setup"));
407 }
408
409 #[test]
410 fn commands_and_registry_verbs_become_codes() {
411 assert_eq!(
412 cli_code(&args(&["pack", "export", "my-secret-pack"])).as_deref(),
413 Some("cli.pack.export")
414 );
415 assert_eq!(
416 cli_code(&args(&["pack", "import", "/home/a/x.ctxpkg"])).as_deref(),
417 Some("cli.pack.import")
418 );
419 assert_eq!(
420 cli_code(&args(&["graph", "build"])).as_deref(),
421 Some("cli.graph.build")
422 );
423 assert_eq!(
424 cli_code(&args(&["index", "build-full"])).as_deref(),
425 Some("cli.index.build_full")
426 );
427 assert_eq!(
428 cli_code(&args(&["report-tokens"])).as_deref(),
429 Some("cli.token_report")
430 );
431 assert_eq!(
432 cli_code(&args(&["--self-update"])).as_deref(),
433 Some("cli.update")
434 );
435 assert_eq!(
436 cli_code(&args(&["cockpit"])).as_deref(),
437 Some("cli.dashboard")
438 );
439 assert_eq!(
440 cli_code(&args(&["telemetry", "off"])).as_deref(),
441 Some("cli.telemetry.off")
442 );
443 }
444
445 #[test]
446 fn user_input_never_becomes_part_of_a_code() {
447 assert_eq!(
448 cli_code(&args(&["knowledge", "remember", "the db password"])).as_deref(),
449 Some("cli.knowledge")
450 );
451 assert_eq!(
452 cli_code(&args(&["graph", "/Users/a/private-repo"])).as_deref(),
453 Some("cli.graph")
454 );
455 assert_eq!(
456 cli_code(&args(&["semantic-search", "export"])).as_deref(),
457 Some("cli.semantic_search.export")
458 );
459 assert!(cli_code(&args(&["my-custom-thing"])).is_none());
460 assert!(cli_code(&args(&[])).is_none());
461 }
462
463 #[test]
464 fn hot_path_commands_are_not_counted() {
465 for command in [
466 "-c",
467 "exec",
468 "hook",
469 "read",
470 "grep",
471 "statusline",
472 "mcp",
473 "__complete",
474 ] {
475 assert!(cli_code(&args(&[command, "build"])).is_none(), "{command}");
476 }
477 }
478
479 #[test]
480 fn every_registry_code_is_a_valid_wire_code() {
481 for (command, aliases) in COMMANDS {
482 for spelling in std::iter::once(*command).chain(aliases.iter().copied()) {
483 let code = cli_code(&args(&[spelling])).unwrap_or_else(|| panic!("{spelling}"));
484 assert_eq!(code, format!("cli.{command}"));
485 for verb in VERBS {
486 assert!(
487 cli_code(&args(&[spelling, verb])).is_some(),
488 "{spelling} {verb}"
489 );
490 }
491 }
492 }
493 for feature in [
494 Feature::GraphIndex,
495 Feature::Bm25Index,
496 Feature::SemanticIndex,
497 ] {
498 assert!(crate::core::telemetry_v2::valid_feature_code(
499 feature.code()
500 ));
501 }
502 for code in [
503 MCP_SESSION,
504 MCP_SESSION_EMPTY,
505 MCP_SESSION_CLEAN,
506 MCP_SESSION_RECOVERED,
507 MCP_SESSION_UNRESOLVED,
508 MCP_SESSION_RETRY,
509 ] {
510 assert!(
511 crate::core::telemetry_v2::valid_feature_code(code),
512 "{code}"
513 );
514 }
515 }
516
517 #[test]
518 fn every_ended_session_gets_exactly_one_outcome() {
519 use crate::core::telemetry::SessionOutcome;
520 use crate::core::telemetry_failure::FailureKind;
521 let outcome = |calls, failures, unresolved: Vec<FailureKind>| SessionOutcome {
522 calls,
523 failures,
524 unresolved,
525 retries: 0,
526 retry_failures: 0,
527 };
528 assert_eq!(
529 mcp_session_end_tallies(&outcome(0, 0, vec![])),
530 [("mcp.session.empty", 1, 0)]
531 );
532 assert_eq!(
533 mcp_session_end_tallies(&outcome(4, 0, vec![]))[0],
534 ("mcp.session.clean", 1, 0)
535 );
536 assert_eq!(
537 mcp_session_end_tallies(&outcome(4, 2, vec![]))[0],
538 ("mcp.session.recovered", 1, 0)
539 );
540 assert_eq!(
541 mcp_session_end_tallies(&outcome(4, 2, vec![FailureKind::NotFound]))[0],
542 ("mcp.session.unresolved", 1, 0),
543 "a missing file is the caller's to resolve"
544 );
545 assert_eq!(
546 mcp_session_end_tallies(&outcome(
547 4,
548 2,
549 vec![FailureKind::NotFound, FailureKind::Timeout]
550 ))[0],
551 ("mcp.session.unresolved", 1, 1),
552 "a timeout is LeanCTX's"
553 );
554 }
555
556 #[test]
557 fn retries_ride_along_with_the_outcome() {
558 let outcome = crate::core::telemetry::SessionOutcome {
559 calls: 9,
560 failures: 3,
561 unresolved: vec![],
562 retries: 3,
563 retry_failures: 1,
564 };
565 assert_eq!(
566 mcp_session_end_tallies(&outcome),
567 [("mcp.session.recovered", 1, 0), ("mcp.session.retry", 3, 1)]
568 );
569 }
570}