mirror of
https://github.com/davila7/claude-code-templates.git
synced 2026-09-19 01:30:23 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/telegram-pr-webhook.py",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
+61
-34
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -877,6 +877,7 @@
|
||||
<!-- Scripts -->
|
||||
<script src="/js/data-loader.js"></script>
|
||||
<script src="/js/cart-manager.js"></script>
|
||||
<script src="/js/event-tracker.js"></script>
|
||||
<script src="/js/component-page.js"></script>
|
||||
<script src="/js/utils.js"></script>
|
||||
|
||||
|
||||
@@ -703,5 +703,6 @@
|
||||
<script src="js/modal-helpers.js"></script>
|
||||
<script src="js/generate-search-data.js"></script>
|
||||
<script src="js/search-functionality.js"></script>
|
||||
<script src="js/event-tracker.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
{
|
||||
"path": "/api/claude-code-check",
|
||||
"schedule": "*/30 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/health-check",
|
||||
"schedule": "*/15 * * * *"
|
||||
}
|
||||
],
|
||||
"rewrites": [
|
||||
|
||||
Reference in New Issue
Block a user