Skip to main content

lean_ctx/core/
telemetry_features.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The closed registry of feature codes for telemetry (3.11.1+).
4//!
5//! A code names *what* LeanCTX did, never with what: `cli.pack.export`,
6//! `index.graph`. CLI arguments are matched against the fixed lists below and
7//! only the matching literal is counted, so a path, file name, query or any
8//! other user input can never become a code. Commands on the hot path of
9//! every shell command or editor event are not counted at all; the daily
10//! usage record already covers that work.
11
12/// Top-level commands counted as `cli.<command>`. Aliases map to the first
13/// spelling the dispatcher accepts.
14const 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    // `cockpit` is the Cockpit's name since 3.12; it stays one feature with
63    // its `dashboard` alias under the existing `cli.dashboard` code.
64    ("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
149/// Second words counted as `cli.<command>.<verb>` when they follow a counted
150/// command. Product verbs only; anything else is counted as the bare command.
151const 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
220/// Commands that run on every shell command, editor event or prompt render.
221/// Counting them would add a locked file write to the hot path.
222const 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/// The feature code for a CLI invocation, or `None` when it is not counted.
259/// A `--dry-run` promises to write nothing, the usage counter included.
260#[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
281/// Counts a CLI invocation. Never fails the command.
282pub 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/// Background features with a fixed code.
289#[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
307/// Counts one run of a background feature and whether it succeeded.
308pub fn record(feature: Feature, ok: bool) {
309    let _ = crate::core::telemetry_aggregate::record_feature(feature.code(), ok);
310}
311
312/// An MCP server that completed the client handshake.
313const MCP_SESSION: &str = "mcp.session";
314/// One that then ended without answering a single tool call: a client that
315/// starts every configured server (often in a throwaway container) and whose
316/// agent never uses LeanCTX. Counted, not hidden, so Ops can tell such starts
317/// from real sessions.
318const MCP_SESSION_EMPTY: &str = "mcp.session.empty";
319/// Every other ended session gets exactly one of three outcomes (3.11.5+):
320/// no tool call failed;
321const MCP_SESSION_CLEAN: &str = "mcp.session.clean";
322/// calls failed, but the last call of every tool succeeded;
323const MCP_SESSION_RECOVERED: &str = "mcp.session.recovered";
324/// the last call of some tool failed. Also counted as a failure when one of
325/// those last failures lies on LeanCTX's side rather than the caller's.
326const MCP_SESSION_UNRESOLVED: &str = "mcp.session.unresolved";
327/// Calls made right after a failed call of the same tool; a failure when the
328/// retry failed again.
329const MCP_SESSION_RETRY: &str = "mcp.session.retry";
330
331/// Counts an MCP server start after the handshake.
332pub fn record_mcp_session_start() {
333    let _ = crate::core::telemetry_aggregate::record_feature(MCP_SESSION, true);
334}
335
336/// A failure LeanCTX itself has to fix: a timeout, an unavailable
337/// dependency, a size limit or an unclassified error. Bad input, a missing
338/// file, a stale edit, a permission or a deliberate policy block are the
339/// caller's to resolve.
340fn 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
351/// The `(code, count, failures)` tallies an ending MCP server adds.
352fn 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
372/// Counts how an MCP session ended. Every exit path (transport closed, parent
373/// gone, idle exit) calls this before its final send; only the first call of
374/// a process counts.
375pub 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}