ai_agent/utils/plugins/
fetch_telemetry.rs1#![allow(dead_code)]
3
4use std::collections::HashSet;
5
6pub 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
46fn 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
62fn extract_host(url_or_spec: &str) -> String {
64 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 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
91fn is_official_repo(url_or_spec: &str) -> bool {
93 url_or_spec.contains("anthropics/claude-plugins-official")
94}
95
96pub 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
123pub 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}