Skip to main content

lean_ctx/core/
telemetry_failure.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Why a built-in tool call failed, as a closed class for telemetry.
4//!
5//! Only the class leaves the machine; the message it is derived from (which
6//! can name files, commands or values) never does. Matching is ordered from
7//! the most specific signal to the most generic one.
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum FailureKind {
16    /// Arguments missing, malformed or of the wrong type.
17    InvalidInput,
18    /// File, symbol, job or tool does not exist.
19    NotFound,
20    /// Path outside the jail, read-only target, OS permission.
21    Permission,
22    /// Refused by a LeanCTX policy, allowlist or gateway rule.
23    PolicyBlocked,
24    /// The call or the command it ran timed out.
25    Timeout,
26    /// An edit no longer matches the file (stale anchor, ambiguous match).
27    Conflict,
28    /// Input or output beyond a size or budget limit.
29    TooLarge,
30    /// A dependency (daemon, provider, network, runtime) is not available.
31    Unavailable,
32    /// Anything else.
33    Other,
34}
35
36pub const FAILURE_KINDS: [FailureKind; 9] = [
37    FailureKind::InvalidInput,
38    FailureKind::NotFound,
39    FailureKind::Permission,
40    FailureKind::PolicyBlocked,
41    FailureKind::Timeout,
42    FailureKind::Conflict,
43    FailureKind::TooLarge,
44    FailureKind::Unavailable,
45    FailureKind::Other,
46];
47
48impl FailureKind {
49    pub fn index(self) -> usize {
50        FAILURE_KINDS
51            .iter()
52            .position(|kind| *kind == self)
53            .unwrap_or(FAILURE_KINDS.len() - 1)
54    }
55
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Self::InvalidInput => "invalid_input",
59            Self::NotFound => "not_found",
60            Self::Permission => "permission",
61            Self::PolicyBlocked => "policy_blocked",
62            Self::Timeout => "timeout",
63            Self::Conflict => "conflict",
64            Self::TooLarge => "too_large",
65            Self::Unavailable => "unavailable",
66            Self::Other => "other",
67        }
68    }
69}
70
71pub const RULES: &[(FailureKind, &[&str])] = &[
72    (
73        FailureKind::PolicyBlocked,
74        &[
75            "[policy blocked]",
76            "policy blocked",
77            "blocked by policy",
78            "denied by policy",
79            "allowlist",
80            "not permitted by",
81            "gateway denied",
82            "egress denied",
83        ],
84    ),
85    (
86        FailureKind::Timeout,
87        &["timed out", "timeout", "deadline exceeded"],
88    ),
89    (
90        FailureKind::Conflict,
91        &[
92            "old_string",
93            "not unique",
94            "no unique",
95            "conflict",
96            "mismatch",
97            "stale",
98            "changed since",
99            "hash does not match",
100            "anchor",
101        ],
102    ),
103    (
104        FailureKind::Permission,
105        &[
106            "permission denied",
107            "access denied",
108            "outside the project",
109            "outside project",
110            "outside of",
111            "path jail",
112            "escapes project root",
113            "read-only",
114            "readonly",
115            "operation not permitted",
116        ],
117    ),
118    (
119        FailureKind::NotFound,
120        &[
121            "not found",
122            "no such file",
123            "does not exist",
124            "doesn't exist",
125            "unknown tool",
126            "no matches",
127            "no such",
128        ],
129    ),
130    (
131        FailureKind::TooLarge,
132        &[
133            "too large",
134            "too long",
135            "exceeds",
136            "limit reached",
137            "over budget",
138        ],
139    ),
140    (
141        FailureKind::Unavailable,
142        &[
143            "unavailable",
144            "not running",
145            "connection refused",
146            "not installed",
147            "not configured",
148            "is disabled",
149            "not enabled",
150            "failed to connect",
151            "network",
152        ],
153    ),
154    (
155        FailureKind::InvalidInput,
156        &[
157            "invalid",
158            "missing required",
159            "missing field",
160            "required parameter",
161            "unknown field",
162            "unknown parameter",
163            "unknown action",
164            "expected",
165            "must be",
166            "cannot parse",
167            "failed to parse",
168        ],
169    ),
170];
171
172/// Classify a failure message; the message itself is discarded.
173pub fn classify(message: &str) -> FailureKind {
174    let lowered = message.to_ascii_lowercase();
175    RULES
176        .iter()
177        .find(|(_, needles)| needles.iter().any(|needle| contains_word(&lowered, needle)))
178        .map_or(FailureKind::Other, |(kind, _)| *kind)
179}
180
181/// `needle` in `haystack` starting at a word boundary, so `unexpected` is not
182/// read as `expected`.
183fn contains_word(haystack: &str, needle: &str) -> bool {
184    haystack.match_indices(needle).any(|(at, _)| {
185        haystack[..at]
186            .chars()
187            .next_back()
188            .is_none_or(|c| !c.is_ascii_alphanumeric())
189    })
190}
191
192/// Failure counts indexed like [`FAILURE_KINDS`].
193pub type KindCounts = [u64; FAILURE_KINDS.len()];
194
195/// A failed call as telemetry sees it: its class and, when one survives
196/// scrubbing, the template of its message (see [`super::failure_template`]).
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Failure {
199    pub kind: FailureKind,
200    pub template: Option<String>,
201}
202
203impl Failure {
204    /// Derive class and template locally; the message itself is dropped.
205    pub fn from_message(message: &str) -> Self {
206        Self {
207            kind: classify(message),
208            template: super::failure_template::message_template(message),
209        }
210    }
211
212    pub fn of_kind(kind: FailureKind) -> Self {
213        Self {
214            kind,
215            template: None,
216        }
217    }
218}
219
220pub fn sub_kinds(observed: &KindCounts, baseline: &KindCounts) -> KindCounts {
221    std::array::from_fn(|index| observed[index].saturating_sub(baseline[index]))
222}
223
224pub fn add_kinds(total: &mut KindCounts, delta: &KindCounts) {
225    for (slot, added) in total.iter_mut().zip(delta) {
226        *slot = slot.saturating_add(*added);
227    }
228}
229
230/// Non-zero classes for the wire, capped so they never exceed `failures`.
231pub fn wire_kinds(counts: &KindCounts, failures: u64) -> Option<BTreeMap<FailureKind, u64>> {
232    let mut remaining = failures;
233    let map: BTreeMap<_, _> = FAILURE_KINDS
234        .iter()
235        .zip(counts)
236        .filter_map(|(kind, count)| {
237            let count = (*count).min(remaining);
238            remaining -= count;
239            (count > 0).then_some((*kind, count))
240        })
241        .collect();
242    (!map.is_empty()).then_some(map)
243}
244
245/// Failure templates reported per tool and day.
246pub const MAX_FAILURE_MESSAGES: usize = 5;
247
248/// The most frequent scrubbed templates of `tool` (keys `tool\ttemplate`),
249/// counts capped so they never exceed `failures`.
250pub fn wire_messages(
251    messages: &BTreeMap<String, u64>,
252    tool: &str,
253    failures: u64,
254) -> Option<Vec<crate::core::telemetry_v2::FailureMessage>> {
255    let prefix = format!("{tool}\t");
256    let mut found: Vec<(&str, u64)> = messages
257        .iter()
258        .filter_map(|(key, count)| Some((key.strip_prefix(&prefix)?, *count)))
259        .filter(|(template, count)| *count > 0 && !template.is_empty())
260        .collect();
261    found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
262    let mut remaining = failures;
263    let list: Vec<_> = found
264        .into_iter()
265        .take(MAX_FAILURE_MESSAGES)
266        .filter_map(|(template, count)| {
267            let count = count.min(remaining);
268            remaining -= count;
269            (count > 0).then(|| crate::core::telemetry_v2::FailureMessage {
270                template: template.to_string(),
271                count,
272            })
273        })
274        .collect();
275    (!list.is_empty()).then_some(list)
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn messages_map_to_closed_classes_most_specific_first() {
284        for (message, kind) in [
285            (
286                "[POLICY BLOCKED] command not in allowlist",
287                FailureKind::PolicyBlocked,
288            ),
289            ("command timed out after 120s", FailureKind::Timeout),
290            ("old_string not found in file", FailureKind::Conflict),
291            (
292                "CONFLICT: line 12 hash mismatch, re-read",
293                FailureKind::Conflict,
294            ),
295            ("path escapes project root: /etc", FailureKind::Permission),
296            ("Permission denied (os error 13)", FailureKind::Permission),
297            (
298                "No such file or directory (os error 2)",
299                FailureKind::NotFound,
300            ),
301            ("file is too large to read in full", FailureKind::TooLarge),
302            ("daemon not running", FailureKind::Unavailable),
303            (
304                "missing required parameter 'path'",
305                FailureKind::InvalidInput,
306            ),
307            ("invalid mode: banana", FailureKind::InvalidInput),
308            ("something odd happened", FailureKind::Other),
309            ("", FailureKind::Other),
310        ] {
311            assert_eq!(classify(message), kind, "{message}");
312        }
313    }
314
315    #[test]
316    fn every_kind_has_a_stable_wire_name_and_index() {
317        for (index, kind) in FAILURE_KINDS.iter().enumerate() {
318            assert_eq!(kind.index(), index);
319            assert_eq!(
320                serde_json::to_string(kind).unwrap(),
321                format!("\"{}\"", kind.as_str())
322            );
323        }
324    }
325}