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:
daniel
2026-02-10 22:08:34 -05:00
parent 029e8eb43d
commit cf7e008c59
15 changed files with 683 additions and 54 deletions
-16
View File
@@ -1,16 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/telegram-pr-webhook.py",
"timeout": 30
}
]
}
]
}
}
+9 -1
View File
@@ -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"
}
}
}
+2 -2
View File
@@ -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
+150
View File
@@ -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
});
}
}
+108
View File
@@ -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
});
}
}
+114
View File
@@ -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
View File
@@ -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++;
}
+76
View File
@@ -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
+1
View File
@@ -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>
+1
View File
@@ -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>
+22
View File
@@ -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() {
+19
View File
@@ -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', () => {
+108
View File
@@ -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();
+8 -1
View File
@@ -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)
});
}
/**
+4
View File
@@ -5,6 +5,10 @@
{
"path": "/api/claude-code-check",
"schedule": "*/30 * * * *"
},
{
"path": "/api/health-check",
"schedule": "*/15 * * * *"
}
],
"rewrites": [