From cf7e008c5927f116b4ff90c4a9e0199542d96313 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 10 Feb 2026 22:08:34 -0500 Subject: [PATCH] feat: Add monitoring points P1-P5 (installation outcomes, website events, API health) - Create 3 Neon tables: installation_outcomes, website_events, api_health_logs - Add API endpoints: track-installation-outcome, track-website-events, health-check - Track CLI installation success/failure with timing and error classification - Add website event tracker (search, cart, component views) with batched sendBeacon - Add health-check cron every 15min with Discord alerts on failure - Integrate tracking in all 6 installIndividual* functions with batchId support Co-Authored-By: Claude Opus 4.6 --- .claude/settings.json | 16 ---- .mcp.json | 10 +- CLAUDE.md | 4 +- api/health-check.js | 150 ++++++++++++++++++++++++++++++ api/track-installation-outcome.js | 108 +++++++++++++++++++++ api/track-website-events.js | 114 +++++++++++++++++++++++ cli-tool/src/index.js | 95 ++++++++++++------- cli-tool/src/tracking-service.js | 76 +++++++++++++++ docs/component.html | 1 + docs/index.html | 1 + docs/js/cart-manager.js | 22 +++++ docs/js/component-page.js | 19 ++++ docs/js/event-tracker.js | 108 +++++++++++++++++++++ docs/js/search-functionality.js | 9 +- vercel.json | 4 + 15 files changed, 683 insertions(+), 54 deletions(-) delete mode 100644 .claude/settings.json create mode 100644 api/health-check.js create mode 100644 api/track-installation-outcome.js create mode 100644 api/track-website-events.js create mode 100644 docs/js/event-tracker.js diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 23df6ffa..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/telegram-pr-webhook.py", - "timeout": 30 - } - ] - } - ] - } -} diff --git a/.mcp.json b/.mcp.json index 0d748e79..96da0e6a 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,15 @@ "mcpServers": { "linear": { "command": "npx", - "args": ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] + "args": [ + "-y", + "mcp-remote", + "https://mcp.linear.app/mcp" + ] + }, + "neon": { + "type": "http", + "url": "https://mcp.neon.tech/mcp" } } } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 58c94008..aa54b4fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,8 +192,8 @@ The `/api` directory contains Vercel Serverless Functions: **`/api/claude-code-check`** - Monitors Claude Code releases -- Vercel Cron: every 4 hours -- Database: Neon (claude_code_versions table) +- Vercel Cron: every 30 minutes +- Database: Neon (claude_code_versions, claude_code_changes, discord_notifications_log, monitoring_metadata tables) ### Deployment Workflow diff --git a/api/health-check.js b/api/health-check.js new file mode 100644 index 00000000..0279335b --- /dev/null +++ b/api/health-check.js @@ -0,0 +1,150 @@ +// API Health Check Endpoint - Neon Database +// Monitors critical API endpoints and logs results +// Called by Vercel Cron every 15 minutes + +import { neon } from '@neondatabase/serverless'; + +function getNeonClient() { + const connectionString = process.env.NEON_DATABASE_URL; + if (!connectionString) { + throw new Error('NEON_DATABASE_URL not configured'); + } + return neon(connectionString); +} + +const ENDPOINTS_TO_CHECK = [ + { url: 'https://www.aitmpl.com/api/track-download-supabase', method: 'OPTIONS' }, + { url: 'https://www.aitmpl.com/api/track-command-usage', method: 'OPTIONS' }, + { url: 'https://www.aitmpl.com/api/track-website-events', method: 'OPTIONS' } +]; + +const TIMEOUT_MS = 10000; + +async function checkEndpoint(endpoint) { + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + const response = await fetch(endpoint.url, { + method: endpoint.method, + signal: controller.signal + }); + + clearTimeout(timeoutId); + const responseTimeMs = Date.now() - startTime; + + return { + endpoint: endpoint.url, + method: endpoint.method, + statusCode: response.status, + responseTimeMs, + errorMessage: null + }; + } catch (error) { + const responseTimeMs = Date.now() - startTime; + return { + endpoint: endpoint.url, + method: endpoint.method, + statusCode: 0, + responseTimeMs, + errorMessage: error.name === 'AbortError' ? 'Timeout' : error.message + }; + } +} + +async function sendDiscordAlert(failures) { + const webhookUrl = process.env.DISCORD_WEBHOOK_URL_CHANGELOG; + if (!webhookUrl || failures.length === 0) return; + + const failureList = failures.map(f => + `- \`${f.endpoint}\`: ${f.errorMessage || `HTTP ${f.statusCode}`} (${f.responseTimeMs}ms)` + ).join('\n'); + + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [{ + title: 'API Health Alert', + description: `The following endpoints are experiencing issues:\n${failureList}`, + color: 0xff4444, + timestamp: new Date().toISOString() + }] + }) + }); + } catch (error) { + console.error('Failed to send Discord alert:', error.message); + } +} + +export default async function handler(req, res) { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + return res.status(200).json({ ok: true }); + } + + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed', allowed: ['GET'] }); + } + + try { + // Check all endpoints in parallel + const results = await Promise.all(ENDPOINTS_TO_CHECK.map(checkEndpoint)); + + const sql = getNeonClient(); + + // Log each result + for (const result of results) { + await sql` + INSERT INTO api_health_logs ( + endpoint, method, status_code, response_time_ms, error_message + ) VALUES ( + ${result.endpoint}, + ${result.method}, + ${result.statusCode}, + ${result.responseTimeMs}, + ${result.errorMessage} + ) + `; + } + + // Identify failures (status >= 500, status 0 for network errors, or timeout > 10s) + const failures = results.filter(r => + r.statusCode === 0 || r.statusCode >= 500 || r.responseTimeMs >= TIMEOUT_MS + ); + + // Send Discord alert if there are failures + if (failures.length > 0) { + await sendDiscordAlert(failures); + } + + const allHealthy = failures.length === 0; + + res.status(200).json({ + success: true, + healthy: allHealthy, + results: results.map(r => ({ + endpoint: r.endpoint, + status: r.statusCode, + responseTime: r.responseTimeMs, + error: r.errorMessage + })), + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Health check error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Health check failed', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }); + } +} diff --git a/api/track-installation-outcome.js b/api/track-installation-outcome.js new file mode 100644 index 00000000..3b42e669 --- /dev/null +++ b/api/track-installation-outcome.js @@ -0,0 +1,108 @@ +// Installation Outcome Tracking API Endpoint - Neon Database +// Tracks CLI installation results (success/failure) for reliability analytics + +import { neon } from '@neondatabase/serverless'; + +function getNeonClient() { + const connectionString = process.env.NEON_DATABASE_URL; + if (!connectionString) { + throw new Error('NEON_DATABASE_URL not configured'); + } + return neon(connectionString); +} + +function validateOutcomeData(data) { + const { componentType, componentName, outcome } = data; + + if (!componentType || !componentName || !outcome) { + return { valid: false, error: 'componentType, componentName, and outcome are required' }; + } + + const validTypes = ['agent', 'command', 'mcp', 'setting', 'hook', 'skill', 'template']; + if (!validTypes.includes(componentType)) { + return { valid: false, error: 'Invalid component type' }; + } + + const validOutcomes = ['success', 'failure', 'partial']; + if (!validOutcomes.includes(outcome)) { + return { valid: false, error: 'Invalid outcome. Must be: success, failure, or partial' }; + } + + if (componentName.length > 255) { + return { valid: false, error: 'Component name too long' }; + } + + return { valid: true }; +} + +export default async function handler(req, res) { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, User-Agent'); + + if (req.method === 'OPTIONS') { + return res.status(200).json({ ok: true }); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed', allowed: ['POST'] }); + } + + try { + const { + componentType, + componentName, + outcome, + errorType, + errorMessage, + durationMs, + cliVersion, + nodeVersion, + platform, + arch, + batchId + } = req.body; + + const validation = validateOutcomeData({ componentType, componentName, outcome }); + if (!validation.valid) { + return res.status(400).json({ error: validation.error }); + } + + const sql = getNeonClient(); + + await sql` + INSERT INTO installation_outcomes ( + component_type, component_name, outcome, + error_type, error_message, duration_ms, + cli_version, node_version, platform, arch, batch_id + ) VALUES ( + ${componentType}, + ${componentName}, + ${outcome}, + ${errorType || null}, + ${errorMessage ? errorMessage.substring(0, 1000) : null}, + ${durationMs || null}, + ${cliVersion || 'unknown'}, + ${nodeVersion || 'unknown'}, + ${platform || 'unknown'}, + ${arch || 'unknown'}, + ${batchId || null} + ) + `; + + res.status(200).json({ + success: true, + message: 'Installation outcome tracked', + data: { componentType, componentName, outcome, timestamp: new Date().toISOString() } + }); + + } catch (error) { + console.error('Installation outcome tracking error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to track installation outcome', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }); + } +} diff --git a/api/track-website-events.js b/api/track-website-events.js new file mode 100644 index 00000000..14e365c5 --- /dev/null +++ b/api/track-website-events.js @@ -0,0 +1,114 @@ +// Website Events Tracking API Endpoint - Neon Database +// Tracks search, cart, and component view events from the website + +import { neon } from '@neondatabase/serverless'; + +function getNeonClient() { + const connectionString = process.env.NEON_DATABASE_URL; + if (!connectionString) { + throw new Error('NEON_DATABASE_URL not configured'); + } + return neon(connectionString); +} + +const VALID_EVENT_TYPES = [ + 'search', + 'cart_add', + 'cart_remove', + 'cart_checkout', + 'component_view', + 'copy_command' +]; + +const MAX_EVENTS_PER_BATCH = 50; + +function validateEventsData(data) { + const { events } = data; + + if (!events || !Array.isArray(events) || events.length === 0) { + return { valid: false, error: 'events array is required and must not be empty' }; + } + + if (events.length > MAX_EVENTS_PER_BATCH) { + return { valid: false, error: `Maximum ${MAX_EVENTS_PER_BATCH} events per batch` }; + } + + for (const event of events) { + if (!event.event_type || !VALID_EVENT_TYPES.includes(event.event_type)) { + return { valid: false, error: `Invalid event_type: ${event.event_type}` }; + } + } + + return { valid: true }; +} + +export default async function handler(req, res) { + // CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + return res.status(200).json({ ok: true }); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed', allowed: ['POST'] }); + } + + try { + const { + events, + session_id, + visitor_id, + screen_width, + referrer + } = req.body; + + const validation = validateEventsData({ events }); + if (!validation.valid) { + return res.status(400).json({ error: validation.error }); + } + + // Extract country from Vercel header + const country = req.headers['x-vercel-ip-country'] || null; + + const sql = getNeonClient(); + + // Insert each event + let inserted = 0; + for (const event of events) { + await sql` + INSERT INTO website_events ( + event_type, event_data, page_path, + referrer, session_id, visitor_id, + country, screen_width + ) VALUES ( + ${event.event_type}, + ${event.event_data ? JSON.stringify(event.event_data) : null}, + ${event.page_path || null}, + ${referrer ? referrer.substring(0, 1000) : null}, + ${session_id || null}, + ${visitor_id || null}, + ${country}, + ${screen_width || null} + ) + `; + inserted++; + } + + res.status(200).json({ + success: true, + message: `${inserted} events tracked`, + data: { count: inserted, timestamp: new Date().toISOString() } + }); + + } catch (error) { + console.error('Website events tracking error:', error); + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to track website events', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }); + } +} diff --git a/cli-tool/src/index.js b/cli-tool/src/index.js index 92db4cbc..27ae6030 100644 --- a/cli-tool/src/index.js +++ b/cli-tool/src/index.js @@ -512,7 +512,8 @@ async function createClaudeConfig(options = {}) { // Individual component installation functions async function installIndividualAgent(agentName, targetDir, options) { console.log(chalk.blue(`🤖 Installing agent: ${agentName}`)); - + const startTime = Date.now(); + try { // Support both category/agent-name and direct agent-name formats let githubUrl; @@ -523,25 +524,26 @@ async function installIndividualAgent(agentName, targetDir, options) { // Direct agent format: api-security-audit githubUrl = `https://raw.githubusercontent.com/davila7/claude-code-templates/main/cli-tool/components/agents/${agentName}.md`; } - + console.log(chalk.gray(`📥 Downloading from GitHub (main branch)...`)); - + const response = await fetch(githubUrl); if (!response.ok) { if (response.status === 404) { console.log(chalk.red(`❌ Agent "${agentName}" not found`)); + trackingService.trackInstallationOutcome('agent', agentName, 'failure', { errorType: 'not_found', durationMs: Date.now() - startTime, batchId: options.batchId }); await showAvailableAgents(); return; } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const agentContent = await response.text(); - + // Create .claude/agents directory if it doesn't exist const agentsDir = path.join(targetDir, '.claude', 'agents'); await fs.ensureDir(agentsDir); - + // Write the agent file - always to flat .claude/agents directory let fileName; if (agentName.includes('/')) { @@ -550,34 +552,37 @@ async function installIndividualAgent(agentName, targetDir, options) { } else { fileName = agentName; } - + const targetFile = path.join(agentsDir, `${fileName}.md`); await fs.writeFile(targetFile, agentContent, 'utf8'); - + if (!options.silent) { console.log(chalk.green(`✅ Agent "${agentName}" installed successfully!`)); console.log(chalk.cyan(`📁 Installed to: ${path.relative(targetDir, targetFile)}`)); console.log(chalk.cyan(`📦 Downloaded from: ${githubUrl}`)); } - + // Track successful agent installation trackingService.trackDownload('agent', agentName, { installation_type: 'individual_component', target_directory: path.relative(process.cwd(), targetDir), source: 'github_main' }); - + trackingService.trackInstallationOutcome('agent', agentName, 'success', { durationMs: Date.now() - startTime, batchId: options.batchId }); + return true; - + } catch (error) { console.log(chalk.red(`❌ Error installing agent: ${error.message}`)); + trackingService.trackInstallationOutcome('agent', agentName, 'failure', { errorType: 'network_error', errorMessage: error.message, durationMs: Date.now() - startTime, batchId: options.batchId }); return false; } } async function installIndividualCommand(commandName, targetDir, options) { console.log(chalk.blue(`⚡ Installing command: ${commandName}`)); - + const startTime = Date.now(); + try { // Support both category/command-name and direct command-name formats let githubUrl; @@ -596,6 +601,7 @@ async function installIndividualCommand(commandName, targetDir, options) { if (response.status === 404) { console.log(chalk.red(`❌ Command "${commandName}" not found`)); console.log(chalk.yellow('Available commands: check-file, generate-tests')); + trackingService.trackInstallationOutcome('command', commandName, 'failure', { errorType: 'not_found', durationMs: Date.now() - startTime, batchId: options.batchId }); return; } throw new Error(`HTTP ${response.status}: ${response.statusText}`); @@ -632,18 +638,21 @@ async function installIndividualCommand(commandName, targetDir, options) { target_directory: path.relative(process.cwd(), targetDir), source: 'github_main' }); - + trackingService.trackInstallationOutcome('command', commandName, 'success', { durationMs: Date.now() - startTime, batchId: options.batchId }); + return true; - + } catch (error) { console.log(chalk.red(`❌ Error installing command: ${error.message}`)); + trackingService.trackInstallationOutcome('command', commandName, 'failure', { errorType: 'network_error', errorMessage: error.message, durationMs: Date.now() - startTime, batchId: options.batchId }); return false; } } async function installIndividualMCP(mcpName, targetDir, options) { console.log(chalk.blue(`🔌 Installing MCP: ${mcpName}`)); - + const startTime = Date.now(); + try { // Support both category/mcp-name and direct mcp-name formats let githubUrl; @@ -662,6 +671,7 @@ async function installIndividualMCP(mcpName, targetDir, options) { if (response.status === 404) { console.log(chalk.red(`❌ MCP "${mcpName}" not found`)); console.log(chalk.yellow('Available MCPs: web-fetch, filesystem-access, github-integration, memory-integration, mysql-integration, postgresql-integration, deepgraph-react, deepgraph-nextjs, deepgraph-typescript, deepgraph-vue')); + trackingService.trackInstallationOutcome('mcp', mcpName, 'failure', { errorType: 'not_found', durationMs: Date.now() - startTime, batchId: options.batchId }); return; } throw new Error(`HTTP ${response.status}: ${response.statusText}`); @@ -718,18 +728,21 @@ async function installIndividualMCP(mcpName, targetDir, options) { servers_count: Object.keys(mergedConfig.mcpServers || {}).length, source: 'github_main' }); - + trackingService.trackInstallationOutcome('mcp', mcpName, 'success', { durationMs: Date.now() - startTime, batchId: options.batchId }); + return true; - + } catch (error) { console.log(chalk.red(`❌ Error installing MCP: ${error.message}`)); + trackingService.trackInstallationOutcome('mcp', mcpName, 'failure', { errorType: 'network_error', errorMessage: error.message, durationMs: Date.now() - startTime, batchId: options.batchId }); return false; } } async function installIndividualSetting(settingName, targetDir, options) { console.log(chalk.blue(`⚙️ Installing setting: ${settingName}`)); - + const startTime = Date.now(); + try { // Support both category/setting-name and direct setting-name formats let githubUrl; @@ -749,6 +762,7 @@ async function installIndividualSetting(settingName, targetDir, options) { console.log(chalk.red(`❌ Setting "${settingName}" not found`)); console.log(chalk.yellow('Available settings: enable-telemetry, disable-telemetry, allow-npm-commands, deny-sensitive-files, use-sonnet, use-haiku, retention-7-days, retention-90-days')); console.log(chalk.yellow('Available statuslines: statusline/context-monitor')); + trackingService.trackInstallationOutcome('setting', settingName, 'failure', { errorType: 'not_found', durationMs: Date.now() - startTime, batchId: options.batchId }); return; } throw new Error(`HTTP ${response.status}: ${response.statusText}`); @@ -1052,17 +1066,20 @@ async function installIndividualSetting(settingName, targetDir, options) { } } + trackingService.trackInstallationOutcome('setting', settingName, successfulInstallations > 0 ? 'success' : 'failure', { durationMs: Date.now() - startTime, batchId: options.batchId }); return successfulInstallations; - + } catch (error) { console.log(chalk.red(`❌ Error installing setting: ${error.message}`)); + trackingService.trackInstallationOutcome('setting', settingName, 'failure', { errorType: 'network_error', errorMessage: error.message, durationMs: Date.now() - startTime, batchId: options.batchId }); return 0; } } async function installIndividualHook(hookName, targetDir, options) { console.log(chalk.blue(`🪝 Installing hook: ${hookName}`)); - + const startTime = Date.now(); + try { // Support both category/hook-name and direct hook-name formats let githubUrl; @@ -1081,6 +1098,7 @@ async function installIndividualHook(hookName, targetDir, options) { if (response.status === 404) { console.log(chalk.red(`❌ Hook "${hookName}" not found`)); console.log(chalk.yellow('Available hooks: notify-before-bash, format-python-files, format-javascript-files, git-add-changes, backup-before-edit, run-tests-after-changes')); + trackingService.trackInstallationOutcome('hook', hookName, 'failure', { errorType: 'not_found', durationMs: Date.now() - startTime, batchId: options.batchId }); return; } throw new Error(`HTTP ${response.status}: ${response.statusText}`); @@ -1369,10 +1387,12 @@ async function installIndividualHook(hookName, targetDir, options) { } } + trackingService.trackInstallationOutcome('hook', hookName, successfulInstallations > 0 ? 'success' : 'failure', { durationMs: Date.now() - startTime, batchId: options.batchId }); return successfulInstallations; - + } catch (error) { console.log(chalk.red(`❌ Error installing hook: ${error.message}`)); + trackingService.trackInstallationOutcome('hook', hookName, 'failure', { errorType: 'network_error', errorMessage: error.message, durationMs: Date.now() - startTime, batchId: options.batchId }); return 0; } } @@ -1536,6 +1556,7 @@ async function getAvailableAgentsFromGitHub() { async function installIndividualSkill(skillName, targetDir, options) { console.log(chalk.blue(`💡 Installing skill: ${skillName}`)); + const startTime = Date.now(); try { // Skills can be in format: "skill-name" or "category/skill-name" @@ -1616,6 +1637,7 @@ async function installIndividualSkill(skillName, targetDir, options) { const skillMdPath = `.claude/skills/${skillBaseName}/SKILL.md`; if (!downloadedFiles[skillMdPath]) { console.log(chalk.red(`❌ SKILL.md not found in skill directory`)); + trackingService.trackInstallationOutcome('skill', skillName, 'failure', { errorType: 'validation_error', errorMessage: 'SKILL.md not found', durationMs: Date.now() - startTime, batchId: options.batchId }); return false; } @@ -1650,11 +1672,13 @@ async function installIndividualSkill(skillName, targetDir, options) { source: 'github_main', total_files: Object.keys(downloadedFiles).length }); + trackingService.trackInstallationOutcome('skill', skillName, 'success', { durationMs: Date.now() - startTime, batchId: options.batchId }); return true; } catch (error) { console.log(chalk.red(`❌ Error installing skill: ${error.message}`)); + trackingService.trackInstallationOutcome('skill', skillName, 'failure', { errorType: 'network_error', errorMessage: error.message, durationMs: Date.now() - startTime, batchId: options.batchId }); return false; } } @@ -1664,7 +1688,8 @@ async function installIndividualSkill(skillName, targetDir, options) { */ async function installMultipleComponents(options, targetDir) { console.log(chalk.blue('🔧 Installing multiple components...')); - + const batchId = Math.random().toString(36).substring(2, 15); + try { const components = { agents: [], @@ -1769,31 +1794,32 @@ async function installMultipleComponents(options, targetDir) { // Install agents for (const agent of components.agents) { console.log(chalk.gray(` Installing agent: ${agent}`)); - const agentSuccess = await installIndividualAgent(agent, targetDir, { ...options, silent: true }); + const agentSuccess = await installIndividualAgent(agent, targetDir, { ...options, silent: true, batchId }); if (agentSuccess) successfullyInstalled++; } - + // Install commands for (const command of components.commands) { console.log(chalk.gray(` Installing command: ${command}`)); - const commandSuccess = await installIndividualCommand(command, targetDir, { ...options, silent: true }); + const commandSuccess = await installIndividualCommand(command, targetDir, { ...options, silent: true, batchId }); if (commandSuccess) successfullyInstalled++; } - + // Install MCPs for (const mcp of components.mcps) { console.log(chalk.gray(` Installing MCP: ${mcp}`)); - const mcpSuccess = await installIndividualMCP(mcp, targetDir, { ...options, silent: true }); + const mcpSuccess = await installIndividualMCP(mcp, targetDir, { ...options, silent: true, batchId }); if (mcpSuccess) successfullyInstalled++; } - + // Install settings (using shared installation locations) for (const setting of components.settings) { console.log(chalk.gray(` Installing setting: ${setting}`)); - const settingSuccess = await installIndividualSetting(setting, targetDir, { - ...options, - silent: true, - sharedInstallLocations: sharedInstallLocations + const settingSuccess = await installIndividualSetting(setting, targetDir, { + ...options, + silent: true, + sharedInstallLocations: sharedInstallLocations, + batchId }); if (settingSuccess > 0) successfullyInstalled++; } @@ -1804,7 +1830,8 @@ async function installMultipleComponents(options, targetDir) { const hookSuccess = await installIndividualHook(hook, targetDir, { ...options, silent: true, - sharedInstallLocations: sharedInstallLocations + sharedInstallLocations: sharedInstallLocations, + batchId }); if (hookSuccess > 0) successfullyInstalled++; } @@ -1812,7 +1839,7 @@ async function installMultipleComponents(options, targetDir) { // Install skills for (const skill of components.skills) { console.log(chalk.gray(` Installing skill: ${skill}`)); - const skillSuccess = await installIndividualSkill(skill, targetDir, { ...options, silent: true }); + const skillSuccess = await installIndividualSkill(skill, targetDir, { ...options, silent: true, batchId }); if (skillSuccess) successfullyInstalled++; } diff --git a/cli-tool/src/tracking-service.js b/cli-tool/src/tracking-service.js index fbd499c1..a562d8fe 100644 --- a/cli-tool/src/tracking-service.js +++ b/cli-tool/src/tracking-service.js @@ -286,6 +286,82 @@ class TrackingService { } } } + /** + * Track installation outcome (success/failure with timing) + * @param {string} componentType - agent, command, mcp, setting, hook, skill, template + * @param {string} componentName - Name of the component + * @param {string} outcome - success, failure, or partial + * @param {object} metadata - { errorType, errorMessage, durationMs, batchId } + */ + async trackInstallationOutcome(componentType, componentName, outcome, metadata = {}) { + if (!this.trackingEnabled) { + return; + } + + try { + const payload = { + componentType, + componentName, + outcome, + errorType: metadata.errorType || null, + errorMessage: metadata.errorMessage || null, + durationMs: metadata.durationMs || null, + cliVersion: this.getCliVersion(), + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + batchId: metadata.batchId || null + }; + + this.sendInstallationOutcome(payload) + .catch(error => { + if (process.env.CCT_DEBUG === 'true') { + console.debug('📊 Installation outcome tracking info (non-critical):', error.message); + } + }); + + } catch (error) { + if (process.env.CCT_DEBUG === 'true') { + console.debug('📊 Installation outcome tracking error (non-critical):', error.message); + } + } + } + + /** + * Send installation outcome to Neon Database + */ + async sendInstallationOutcome(payload) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch('https://www.aitmpl.com/api/track-installation-outcome', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `claude-code-templates/${payload.cliVersion}` + }, + body: JSON.stringify(payload), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (process.env.CCT_DEBUG === 'true') { + if (response.ok) { + console.debug('📊 Installation outcome tracked successfully'); + } else { + console.debug(`📊 Installation outcome tracking failed with status: ${response.status}`); + } + } + + } catch (error) { + clearTimeout(timeoutId); + if (process.env.CCT_DEBUG === 'true') { + console.debug('📊 Installation outcome tracking failed (non-critical):', error.message); + } + } + } } // Export singleton instance diff --git a/docs/component.html b/docs/component.html index 3859b66b..aee8fa46 100644 --- a/docs/component.html +++ b/docs/component.html @@ -877,6 +877,7 @@ + diff --git a/docs/index.html b/docs/index.html index 9fa8e1ac..f67aed06 100644 --- a/docs/index.html +++ b/docs/index.html @@ -703,5 +703,6 @@ + \ No newline at end of file diff --git a/docs/js/cart-manager.js b/docs/js/cart-manager.js index f54bed71..42698c36 100644 --- a/docs/js/cart-manager.js +++ b/docs/js/cart-manager.js @@ -67,6 +67,14 @@ class CartManager { this.updateCartUI(); this.updateFloatingButton(); this.showNotification(`${item.name} added to stack!`, 'success'); + + // Track cart add event + window.eventTracker?.track('cart_add', { + component_type: type, + component_name: item.name, + cart_size: this.getTotalItems() + }); + return true; } @@ -78,11 +86,19 @@ class CartManager { return; } + const removedItem = this.cart[type].find(item => item.path === itemPath); this.cart[type] = this.cart[type].filter(item => item.path !== itemPath); this.saveCartToStorage(); this.updateCartUI(); this.updateFloatingButton(); this.showNotification('Item removed from stack', 'info'); + + // Track cart remove event + window.eventTracker?.track('cart_remove', { + component_type: type, + component_name: removedItem?.name || itemPath, + cart_size: this.getTotalItems() + }); } // Clear entire cart @@ -581,6 +597,12 @@ function clearCart() { function copyCartCommand() { const command = document.getElementById('generatedCommand').textContent; copyToClipboard(command, 'Command copied to clipboard!'); + + // Track cart checkout (copy command) + const cart = cartManager.cart; + const types = Object.keys(cart).filter(k => cart[k].length > 0); + const items = types.reduce((sum, k) => sum + cart[k].length, 0); + window.eventTracker?.track('cart_checkout', { items, types }); } function downloadStack() { diff --git a/docs/js/component-page.js b/docs/js/component-page.js index b66c8d8f..f202e5bf 100644 --- a/docs/js/component-page.js +++ b/docs/js/component-page.js @@ -132,6 +132,13 @@ class ComponentPageManager { // Activate tab from URL hash this.activateTabFromHash(); + + // Track component view + window.eventTracker?.track('component_view', { + component_type: this.component.type, + component_name: this.component.name, + category: this.component.category || null + }); } @@ -873,6 +880,18 @@ class ComponentPageManager { setupEventListeners() { + // Track copy command clicks on installation buttons + document.addEventListener('click', (e) => { + const copyBtn = e.target.closest('.copy-btn, .quick-copy-btn'); + if (copyBtn && this.component) { + window.eventTracker?.track('copy_command', { + component_type: this.component.type, + component_name: this.component.name, + source: 'detail_page' + }); + } + }); + // Tab switching document.querySelectorAll('.component-tabs .tab-btn').forEach(btn => { btn.addEventListener('click', () => { diff --git a/docs/js/event-tracker.js b/docs/js/event-tracker.js new file mode 100644 index 00000000..9a9c88c5 --- /dev/null +++ b/docs/js/event-tracker.js @@ -0,0 +1,108 @@ +// Event Tracker for Claude Code Templates Website +// Batches and sends website analytics events (search, cart, component views) + +class EventTracker { + constructor() { + this.queue = []; + this.flushInterval = 30000; // 30 seconds + this.maxQueueSize = 20; + this.endpoint = '/api/track-website-events'; + this.sessionId = this.getOrCreateSessionId(); + this.visitorId = this.getOrCreateVisitorId(); + this.screenWidth = window.screen?.width || null; + this.referrer = document.referrer || null; + this.timer = null; + + this.startAutoFlush(); + this.setupBeforeUnload(); + } + + getOrCreateSessionId() { + let id = sessionStorage.getItem('cct_session_id'); + if (!id) { + id = this.generateId(); + sessionStorage.setItem('cct_session_id', id); + } + return id; + } + + getOrCreateVisitorId() { + let id = localStorage.getItem('cct_visitor_id'); + if (!id) { + id = this.generateId(); + localStorage.setItem('cct_visitor_id', id); + } + return id; + } + + generateId() { + return Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + } + + track(eventType, eventData) { + this.queue.push({ + event_type: eventType, + event_data: eventData || {}, + page_path: window.location.pathname, + timestamp: new Date().toISOString() + }); + + if (this.queue.length >= this.maxQueueSize) { + this.flush(); + } + } + + flush() { + if (this.queue.length === 0) return; + + const events = this.queue.splice(0); + const payload = JSON.stringify({ + events: events, + session_id: this.sessionId, + visitor_id: this.visitorId, + screen_width: this.screenWidth, + referrer: this.referrer + }); + + // Use sendBeacon for reliability (survives page unloads) + if (navigator.sendBeacon) { + const blob = new Blob([payload], { type: 'application/json' }); + const sent = navigator.sendBeacon(this.endpoint, blob); + if (!sent) { + // Fallback to fetch if sendBeacon fails + this.sendViaFetch(payload); + } + } else { + this.sendViaFetch(payload); + } + } + + sendViaFetch(payload) { + fetch(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true + }).catch(() => { + // Silent failure - analytics should never break user experience + }); + } + + startAutoFlush() { + this.timer = setInterval(() => this.flush(), this.flushInterval); + } + + setupBeforeUnload() { + window.addEventListener('beforeunload', () => this.flush()); + // Also flush on visibility change (mobile tab switching) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.flush(); + } + }); + } +} + +// Initialize global instance +window.eventTracker = new EventTracker(); diff --git a/docs/js/search-functionality.js b/docs/js/search-functionality.js index cee7d4b4..4d1edaf1 100644 --- a/docs/js/search-functionality.js +++ b/docs/js/search-functionality.js @@ -318,10 +318,17 @@ function performSearch(query) { // Sort by match score (highest first) results.sort((a, b) => b.matchScore - a.matchScore); - + searchResults = results; updateSearchResults(results, categoryMatches); displaySearchResults(results); + + // Track search event + window.eventTracker?.track('search', { + query: query, + results_count: results.length, + categories_matched: Array.from(categoryMatches) + }); } /** diff --git a/vercel.json b/vercel.json index 0b3bd038..1d593ff3 100644 --- a/vercel.json +++ b/vercel.json @@ -5,6 +5,10 @@ { "path": "/api/claude-code-check", "schedule": "*/30 * * * *" + }, + { + "path": "/api/health-check", + "schedule": "*/15 * * * *" } ], "rewrites": [