Skip to main content

ai_agent/utils/plugins/
fetch_telemetry.rs

1// Source: ~/claudecode/openclaudecode/src/utils/plugins/fetchTelemetry.ts
2#![allow(dead_code)]
3
4use std::collections::HashSet;
5
6/// Telemetry for plugin/marketplace fetches that hit the network.
7
8pub enum PluginFetchSource {
9    InstallCounts,
10    MarketplaceClone,
11    MarketplacePull,
12    MarketplaceUrl,
13    PluginClone,
14    Mcpb,
15}
16
17impl PluginFetchSource {
18    pub fn as_str(&self) -> &'static str {
19        match self {
20            Self::InstallCounts => "install_counts",
21            Self::MarketplaceClone => "marketplace_clone",
22            Self::MarketplacePull => "marketplace_pull",
23            Self::MarketplaceUrl => "marketplace_url",
24            Self::PluginClone => "plugin_clone",
25            Self::Mcpb => "mcpb",
26        }
27    }
28}
29
30pub enum PluginFetchOutcome {
31    Success,
32    Failure,
33    CacheHit,
34}
35
36impl PluginFetchOutcome {
37    pub fn as_str(&self) -> &'static str {
38        match self {
39            Self::Success => "success",
40            Self::Failure => "failure",
41            Self::CacheHit => "cache_hit",
42        }
43    }
44}
45
46/// Allowlist of public hosts we report by name.
47fn known_public_hosts() -> HashSet<&'static str> {
48    HashSet::from([
49        "github.com",
50        "raw.githubusercontent.com",
51        "objects.githubusercontent.com",
52        "gist.githubusercontent.com",
53        "gitlab.com",
54        "bitbucket.org",
55        "codeberg.org",
56        "dev.azure.com",
57        "ssh.dev.azure.com",
58        "storage.googleapis.com",
59    ])
60}
61
62/// Extract hostname from a URL and bucket to the allowlist.
63fn extract_host(url_or_spec: &str) -> String {
64    // Check SCP-style: user@host:path
65    if let Some(pos) = url_or_spec.find('@') {
66        if let Some(colon_pos) = url_or_spec[pos..].find(':') {
67            let host = &url_or_spec[pos + 1..pos + colon_pos];
68            let normalized = host.to_lowercase();
69            return if known_public_hosts().contains(normalized.as_str()) {
70                normalized
71            } else {
72                "other".to_string()
73            };
74        }
75    }
76
77    // Try parsing as URL
78    match url::Url::parse(url_or_spec) {
79        Ok(url) => {
80            let normalized = url.host_str().unwrap_or("").to_lowercase();
81            if known_public_hosts().contains(normalized.as_str()) {
82                normalized
83            } else {
84                "other".to_string()
85            }
86        }
87        Err(_) => "unknown".to_string(),
88    }
89}
90
91/// Check if URL points at anthropics/claude-plugins-official.
92fn is_official_repo(url_or_spec: &str) -> bool {
93    url_or_spec.contains("anthropics/claude-plugins-official")
94}
95
96/// Log a plugin fetch event for telemetry.
97pub fn log_plugin_fetch(
98    source: PluginFetchSource,
99    url_or_spec: Option<&str>,
100    outcome: PluginFetchOutcome,
101    duration_ms: u64,
102    error_kind: Option<&str>,
103) {
104    let host = url_or_spec
105        .map(extract_host)
106        .unwrap_or_else(|| "unknown".to_string());
107    let is_official = url_or_spec.map_or(false, is_official_repo);
108
109    let mut metadata = std::collections::HashMap::new();
110    metadata.insert("source".to_string(), serde_json::json!(source.as_str()));
111    metadata.insert("host".to_string(), serde_json::json!(host));
112    metadata.insert("is_official".to_string(), serde_json::json!(is_official));
113    metadata.insert("outcome".to_string(), serde_json::json!(outcome.as_str()));
114    metadata.insert("duration_ms".to_string(), serde_json::json!(duration_ms));
115    metadata.insert(
116        "error_kind".to_string(),
117        serde_json::json!(error_kind.unwrap_or("")),
118    );
119
120    crate::services::analytics::log_event("tengu_plugin_remote_fetch", metadata);
121}
122
123/// Classify an error into a stable bucket for telemetry.
124pub fn classify_fetch_error(error: &dyn std::error::Error) -> String {
125    let msg = error.to_string().to_lowercase();
126
127    if msg.contains("enotfound")
128        || msg.contains("econnrefused")
129        || msg.contains("eai_again")
130        || msg.contains("could not resolve host")
131        || msg.contains("connection refused")
132    {
133        return "dns_or_refused".to_string();
134    }
135    if msg.contains("etimedout") || msg.contains("timed out") || msg.contains("timeout") {
136        return "timeout".to_string();
137    }
138    if msg.contains("econnreset")
139        || msg.contains("socket hang up")
140        || msg.contains("connection reset by peer")
141        || msg.contains("remote end hung up")
142    {
143        return "conn_reset".to_string();
144    }
145    if msg.contains("403") || msg.contains("401") || msg.contains("permission denied") {
146        return "auth".to_string();
147    }
148    if msg.contains("404") || msg.contains("not found") || msg.contains("repository not found") {
149        return "not_found".to_string();
150    }
151    if msg.contains("certificate") || msg.contains("ssl") || msg.contains("tls") {
152        return "tls".to_string();
153    }
154    if msg.contains("invalid response format") || msg.contains("invalid marketplace schema") {
155        return "invalid_schema".to_string();
156    }
157    "other".to_string()
158}