Skip to main content

ccboard_core/analytics/
plugin_usage.rs

1//! Plugin usage analytics module
2//!
3//! Aggregates tool usage from SessionMetadata to classify and rank plugins:
4//! - Skills (.claude/skills/)
5//! - Commands (.claude/commands/)
6//! - MCP Servers (mcp__server__tool format)
7//! - Agents (Task tool with subagent_type)
8//! - Native Tools (Read, Write, Edit, Bash, etc.)
9//!
10//! Provides metrics on total invocations, cost attribution, dead code detection.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::{HashMap, HashSet};
15use std::sync::Arc;
16
17use crate::models::session::SessionMetadata;
18use crate::pricing::calculate_cost;
19
20/// Plugin classification by origin
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum PluginType {
23    /// User-defined skill from .claude/skills/
24    Skill,
25    /// User-defined command from .claude/commands/
26    Command,
27    /// Spawned agent via Task tool
28    Agent,
29    /// MCP server tool (mcp__server__tool format)
30    McpServer,
31    /// Built-in Claude Code tool
32    NativeTool,
33}
34
35impl PluginType {
36    /// Icon for TUI/Web display
37    pub fn icon(&self) -> &'static str {
38        match self {
39            Self::Skill => "🎓",
40            Self::Command => "⚡",
41            Self::Agent => "🤖",
42            Self::McpServer => "🔌",
43            Self::NativeTool => "🛠️",
44        }
45    }
46
47    /// Human-readable label
48    pub fn label(&self) -> &'static str {
49        match self {
50            Self::Skill => "Skill",
51            Self::Command => "Command",
52            Self::Agent => "Agent",
53            Self::McpServer => "MCP Server",
54            Self::NativeTool => "Native Tool",
55        }
56    }
57}
58
59/// Usage statistics for a single plugin
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PluginUsage {
62    /// Plugin identifier (e.g., "rust-expert", "mcp__context7__search")
63    pub name: String,
64    /// Classification
65    pub plugin_type: PluginType,
66    /// Icon emoji (computed from plugin_type, serialized for WASM compatibility)
67    pub icon: String,
68    /// Total invocations across all sessions
69    pub total_invocations: usize,
70    /// Session IDs where this plugin was used
71    pub sessions_used: Vec<String>,
72    /// Total cost attributed to this plugin ($)
73    pub total_cost: f64,
74    /// Average tokens per invocation
75    pub avg_tokens_per_invocation: u64,
76    /// First usage timestamp
77    pub first_seen: DateTime<Utc>,
78    /// Last usage timestamp
79    pub last_seen: DateTime<Utc>,
80}
81
82impl PluginUsage {
83    /// Create new plugin usage record
84    fn new(
85        name: String,
86        plugin_type: PluginType,
87        invocations: usize,
88        session_id: String,
89        cost: f64,
90        avg_tokens: u64,
91        timestamp: DateTime<Utc>,
92    ) -> Self {
93        Self {
94            name,
95            icon: plugin_type.icon().to_string(),
96            plugin_type,
97            total_invocations: invocations,
98            sessions_used: vec![session_id],
99            total_cost: cost,
100            avg_tokens_per_invocation: avg_tokens,
101            first_seen: timestamp,
102            last_seen: timestamp,
103        }
104    }
105
106    /// Merge another usage record into this one
107    #[allow(dead_code)]
108    fn merge(&mut self, other: &Self) {
109        self.total_invocations += other.total_invocations;
110        if !self.sessions_used.contains(&other.sessions_used[0]) {
111            self.sessions_used.push(other.sessions_used[0].clone());
112        }
113        self.total_cost += other.total_cost;
114        if other.first_seen < self.first_seen {
115            self.first_seen = other.first_seen;
116        }
117        if other.last_seen > self.last_seen {
118            self.last_seen = other.last_seen;
119        }
120    }
121}
122
123/// Complete plugin analytics data
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct PluginAnalytics {
126    /// Total plugin count (active + dead)
127    pub total_plugins: usize,
128    /// Active plugin count (used at least once)
129    pub active_plugins: usize,
130    /// Dead plugins (defined but never used)
131    pub dead_plugins: Vec<String>,
132    /// All plugin usage records
133    pub plugins: Vec<PluginUsage>,
134    /// Top 10 by usage
135    pub top_by_usage: Vec<PluginUsage>,
136    /// Top 10 by cost
137    pub top_by_cost: Vec<PluginUsage>,
138    /// Timestamp of computation
139    pub computed_at: DateTime<Utc>,
140}
141
142impl PluginAnalytics {
143    /// Create empty analytics
144    pub fn empty() -> Self {
145        Self {
146            total_plugins: 0,
147            active_plugins: 0,
148            dead_plugins: Vec::new(),
149            plugins: Vec::new(),
150            top_by_usage: Vec::new(),
151            top_by_cost: Vec::new(),
152            computed_at: Utc::now(),
153        }
154    }
155}
156
157/// Classify plugin by name and context
158fn classify_plugin(name: &str, skills: &[String], commands: &[String]) -> PluginType {
159    // MCP server format: mcp__server__tool
160    if name.starts_with("mcp__") {
161        return PluginType::McpServer;
162    }
163
164    // Agent spawning via Task tool
165    if name == "Task" {
166        return PluginType::Agent;
167    }
168
169    // Check against known skills (case-insensitive)
170    let lower_name = name.to_lowercase();
171    if skills
172        .iter()
173        .any(|s| lower_name.contains(&s.to_lowercase()))
174    {
175        return PluginType::Skill;
176    }
177
178    // Check against known commands
179    if commands
180        .iter()
181        .any(|c| lower_name.contains(&c.to_lowercase()))
182    {
183        return PluginType::Command;
184    }
185
186    // Known native tools
187    const NATIVE_TOOLS: &[&str] = &[
188        "Read",
189        "Write",
190        "Edit",
191        "MultiEdit",
192        "Bash",
193        "Grep",
194        "Glob",
195        "WebSearch",
196        "WebFetch",
197        "NotebookEdit",
198        "AskUserQuestion",
199        "EnterPlanMode",
200        "ExitPlanMode",
201        "TaskCreate",
202        "TaskUpdate",
203        "TaskGet",
204        "TaskList",
205        "TeamCreate",
206        "TeamDelete",
207        "SendMessage",
208        "Skill",
209    ];
210
211    if NATIVE_TOOLS.contains(&name) {
212        return PluginType::NativeTool;
213    }
214
215    // Default: treat as native tool
216    PluginType::NativeTool
217}
218
219/// Aggregate plugin usage from sessions
220///
221/// # Arguments
222/// - `sessions`: All sessions to analyze
223/// - `available_skills`: Skill names from .claude/skills/*.md
224/// - `available_commands`: Command names from .claude/commands/*.md
225///
226/// # Returns
227/// Complete plugin analytics with rankings and dead code detection
228pub fn aggregate_plugin_usage(
229    sessions: &[Arc<SessionMetadata>],
230    available_skills: &[String],
231    available_commands: &[String],
232) -> PluginAnalytics {
233    let mut usage_map: HashMap<String, PluginUsage> = HashMap::new();
234
235    // Aggregate tool usage from all sessions
236    for session in sessions {
237        // Calculate session cost (proportional attribution)
238        // Use first model from models_used, or default to sonnet-4.5
239        let model = session
240            .models_used
241            .first()
242            .map(|s| s.as_str())
243            .unwrap_or("sonnet-4.5");
244        let session_cost = calculate_cost(
245            model,
246            session.input_tokens,
247            session.output_tokens,
248            session.cache_creation_tokens,
249            session.cache_read_tokens,
250        );
251        let session_tokens = session.total_tokens;
252
253        // Skip sessions with no tool usage
254        if session.tool_usage.is_empty() {
255            continue;
256        }
257
258        // Total tool calls in session (for proportional cost)
259        let total_calls: usize = session.tool_usage.values().sum();
260        if total_calls == 0 {
261            continue;
262        }
263
264        for (tool_name, call_count) in &session.tool_usage {
265            // Skip empty tool names (malformed session data)
266            if tool_name.is_empty() {
267                continue;
268            }
269
270            let plugin_type = classify_plugin(tool_name, available_skills, available_commands);
271
272            // Proportional cost attribution
273            let tool_cost = session_cost * (*call_count as f64 / total_calls as f64);
274
275            // Average tokens per call (rough approximation)
276            let avg_tokens = if *call_count > 0 {
277                session_tokens / *call_count as u64
278            } else {
279                0
280            };
281
282            // Use first/last timestamp from session
283            let timestamp = session
284                .first_timestamp
285                .or(session.last_timestamp)
286                .unwrap_or_else(Utc::now);
287
288            usage_map
289                .entry(tool_name.clone())
290                .and_modify(|usage| {
291                    usage.total_invocations += call_count;
292                    if !usage.sessions_used.contains(&session.id.to_string()) {
293                        usage.sessions_used.push(session.id.to_string());
294                    }
295                    usage.total_cost += tool_cost;
296
297                    // Update timestamps
298                    if let Some(first_ts) = session.first_timestamp {
299                        if first_ts < usage.first_seen {
300                            usage.first_seen = first_ts;
301                        }
302                    }
303                    if let Some(last_ts) = session.last_timestamp {
304                        if last_ts > usage.last_seen {
305                            usage.last_seen = last_ts;
306                        }
307                    }
308
309                    // Recalculate average tokens (weighted by invocations)
310                    usage.avg_tokens_per_invocation = (usage.avg_tokens_per_invocation
311                        * (usage.total_invocations - call_count) as u64
312                        + avg_tokens * *call_count as u64)
313                        / usage.total_invocations as u64;
314                })
315                .or_insert_with(|| {
316                    PluginUsage::new(
317                        tool_name.clone(),
318                        plugin_type,
319                        *call_count,
320                        session.id.to_string(),
321                        tool_cost,
322                        avg_tokens,
323                        timestamp,
324                    )
325                });
326        }
327    }
328
329    // Identify dead code (defined but never used)
330    let used_names: HashSet<_> = usage_map.keys().map(|s| s.to_lowercase()).collect();
331    let dead_plugins: Vec<String> = available_skills
332        .iter()
333        .chain(available_commands.iter())
334        .filter(|name| !used_names.contains(&name.to_lowercase()))
335        .cloned()
336        .collect();
337
338    // Convert to sorted vector
339    let mut plugins: Vec<_> = usage_map.into_values().collect();
340    plugins.sort_by(|a, b| b.total_invocations.cmp(&a.total_invocations));
341
342    // Top 10 by usage
343    let top_by_usage = plugins.iter().take(10).cloned().collect();
344
345    // Top 10 by cost
346    let mut top_by_cost = plugins.clone();
347    top_by_cost.sort_by(|a, b| {
348        b.total_cost
349            .partial_cmp(&a.total_cost)
350            .unwrap_or(std::cmp::Ordering::Equal)
351    });
352    let top_by_cost = top_by_cost.into_iter().take(10).collect();
353
354    PluginAnalytics {
355        total_plugins: plugins.len() + dead_plugins.len(),
356        active_plugins: plugins.len(),
357        dead_plugins,
358        plugins,
359        top_by_usage,
360        top_by_cost,
361        computed_at: Utc::now(),
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_classify_plugin() {
371        let skills = vec!["rust-expert".to_string(), "boldguy-draft".to_string()];
372        let commands = vec!["commit".to_string()];
373
374        assert_eq!(
375            classify_plugin("rust-expert", &skills, &commands),
376            PluginType::Skill
377        );
378        assert_eq!(
379            classify_plugin("mcp__context7__search", &skills, &commands),
380            PluginType::McpServer
381        );
382        assert_eq!(
383            classify_plugin("Read", &skills, &commands),
384            PluginType::NativeTool
385        );
386        assert_eq!(
387            classify_plugin("Task", &skills, &commands),
388            PluginType::Agent
389        );
390    }
391
392    #[test]
393    fn test_aggregate_plugin_usage() {
394        use std::path::PathBuf;
395
396        // Create test sessions with tool_usage
397        let mut tool_usage1 = HashMap::new();
398        tool_usage1.insert("rust-expert".to_string(), 5);
399        tool_usage1.insert("mcp__context7__search".to_string(), 3);
400
401        let mut session1 = SessionMetadata::from_path(
402            PathBuf::from("/tmp/test-session.jsonl"),
403            "test-project".into(),
404        );
405        session1.tool_usage = tool_usage1;
406        session1.total_tokens = 10000;
407        session1.first_timestamp = Some(Utc::now());
408        session1.last_timestamp = Some(Utc::now());
409
410        let sessions = vec![Arc::new(session1)];
411        let skills = vec!["rust-expert".to_string()];
412        let commands = vec![];
413
414        let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
415
416        assert_eq!(analytics.active_plugins, 2);
417        assert_eq!(analytics.plugins[0].name, "rust-expert");
418        assert_eq!(analytics.plugins[0].total_invocations, 5);
419        assert_eq!(analytics.plugins[0].plugin_type, PluginType::Skill);
420        assert_eq!(analytics.plugins[1].name, "mcp__context7__search");
421        assert_eq!(analytics.plugins[1].plugin_type, PluginType::McpServer);
422    }
423
424    #[test]
425    fn test_dead_code_detection() {
426        let sessions = vec![];
427        let skills = vec!["rust-expert".to_string(), "unused-skill".to_string()];
428        let commands = vec!["commit".to_string(), "never-used".to_string()];
429
430        let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
431
432        assert_eq!(analytics.active_plugins, 0);
433        assert_eq!(analytics.dead_plugins.len(), 4); // All skills + commands unused
434        assert!(analytics.dead_plugins.contains(&"unused-skill".to_string()));
435        assert!(analytics.dead_plugins.contains(&"never-used".to_string()));
436    }
437
438    #[test]
439    fn test_empty_sessions() {
440        let sessions = vec![];
441        let skills = vec![];
442        let commands = vec![];
443
444        let analytics = aggregate_plugin_usage(&sessions, &skills, &commands);
445
446        assert_eq!(analytics.active_plugins, 0);
447        assert_eq!(analytics.total_plugins, 0);
448        assert!(analytics.plugins.is_empty());
449    }
450}