feat: Complete analytics system with GTM, GA4, Clarity, Ads tracking, bot detection

This commit is contained in:
cawcenter
2025-12-12 10:05:00 -05:00
parent fd0e2fd20e
commit 7f876efd69
6 changed files with 611 additions and 1 deletions

View File

@@ -0,0 +1,150 @@
// @ts-ignore - Astro types available at build time
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems } from '@/lib/directus/client';
/**
* Analytics Dashboard API
*
* Returns analytics data for a site.
*
* GET /api/analytics/dashboard?site_id={id}&period=7d
*/
export const GET: APIRoute = async ({ url }: { url: URL }) => {
try {
const siteId = url.searchParams.get('site_id');
const period = url.searchParams.get('period') || '7d'; // 7d, 30d, 90d
if (!siteId) {
return new Response(
JSON.stringify({ error: 'site_id is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Calculate date range
const periodDays = parseInt(period) || 7;
const startDate = new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000).toISOString();
const directus = getDirectusClient();
// Parallel data fetches
const [
totalPageviews,
uniqueVisitors,
botPageviews,
topPages,
topReferrers,
deviceBreakdown,
browserBreakdown,
utmSources,
recentEvents,
conversions
] = await Promise.all([
// Total pageviews
directus.request(readItems('pageviews', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
aggregate: { count: '*' }
})),
// Unique visitors
directus.request(readItems('pageviews', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
groupBy: ['visitor_id'],
aggregate: { count: '*' }
})),
// Bot pageviews
directus.request(readItems('pageviews', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: true } },
aggregate: { count: '*' }
})),
// Top pages
directus.request(readItems('pageviews', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
groupBy: ['page_path'],
aggregate: { count: '*' },
sort: ['-count'],
limit: 10
})),
// Top referrers
directus.request(readItems('pageviews', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false }, referrer: { _nnull: true } },
groupBy: ['referrer'],
aggregate: { count: '*' },
sort: ['-count'],
limit: 10
})),
// Device breakdown
directus.request(readItems('pageviews', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
groupBy: ['device_type'],
aggregate: { count: '*' }
})),
// Browser breakdown
directus.request(readItems('pageviews', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
groupBy: ['browser'],
aggregate: { count: '*' }
})),
// UTM sources
directus.request(readItems('pageviews', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, utm_source: { _nnull: true } },
groupBy: ['utm_source', 'utm_medium', 'utm_campaign'],
aggregate: { count: '*' },
sort: ['-count'],
limit: 10
})),
// Recent events
directus.request(readItems('events', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate } },
groupBy: ['event_name'],
aggregate: { count: '*' },
sort: ['-count'],
limit: 10
})),
// Conversions
directus.request(readItems('conversions', {
filter: { site: { _eq: siteId }, timestamp: { _gte: startDate } },
aggregate: { count: '*', sum: 'value' }
}))
]);
const dashboard = {
period,
period_days: periodDays,
overview: {
total_pageviews: parseInt((totalPageviews as any)?.[0]?.count || '0', 10),
unique_visitors: (uniqueVisitors as any[])?.length || 0,
bot_pageviews: parseInt((botPageviews as any)?.[0]?.count || '0', 10),
total_conversions: parseInt((conversions as any)?.[0]?.count || '0', 10),
total_revenue: parseFloat((conversions as any)?.[0]?.sum?.value || '0')
},
top_pages: (topPages as any[]).map(p => ({ path: p.page_path, views: parseInt(p.count, 10) })),
top_referrers: (topReferrers as any[]).map(r => ({ referrer: r.referrer, views: parseInt(r.count, 10) })),
devices: (deviceBreakdown as any[]).reduce((acc, d) => {
acc[d.device_type] = parseInt(d.count, 10);
return acc;
}, {} as Record<string, number>),
browsers: (browserBreakdown as any[]).reduce((acc, b) => {
acc[b.browser] = parseInt(b.count, 10);
return acc;
}, {} as Record<string, number>),
utm_sources: (utmSources as any[]).map(u => ({
source: u.utm_source,
medium: u.utm_medium,
campaign: u.utm_campaign,
visits: parseInt(u.count, 10)
})),
top_events: (recentEvents as any[]).map(e => ({ name: e.event_name, count: parseInt(e.count, 10) }))
};
return new Response(
JSON.stringify(dashboard),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error fetching analytics:', error);
return new Response(
JSON.stringify({ error: 'Failed to fetch analytics' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -10,7 +10,7 @@ import { getDirectusClient, readItems, aggregate } from '@/lib/directus/client';
*
* GET /api/client/dashboard?site_id={id}
*/
export const GET: APIRoute = async ({ url }) => {
export const GET: APIRoute = async ({ url }: { url: URL }) => {
try {
const siteId = url.searchParams.get('site_id');

View File

@@ -0,0 +1,105 @@
// @ts-ignore - Astro types available at build time
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
/**
* Conversion Tracking API
*
* Records and optionally sends conversions to Google Ads and Facebook.
*
* POST /api/track/conversion
*/
export const POST: APIRoute = async ({ request }: { request: Request }) => {
try {
const data = await request.json();
const {
site_id,
lead_id,
conversion_type,
value,
currency = 'USD',
source,
campaign,
gclid,
fbclid,
send_to_google = false,
send_to_facebook = false
} = data;
if (!site_id || !conversion_type) {
return new Response(
JSON.stringify({ error: 'site_id and conversion_type are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
// Create conversion record
const conversion = await directus.request(
createItem('conversions', {
site: site_id,
lead: lead_id,
conversion_type,
value,
currency,
source,
campaign,
gclid,
fbclid,
sent_to_google: false,
sent_to_facebook: false
})
);
// Get site analytics config for sending to ad platforms
const analyticsConfig = await directus.request(
readItems('site_analytics', {
filter: { site: { _eq: site_id } },
limit: 1
})
);
const config = (analyticsConfig as any[])?.[0];
const results = { google: false, facebook: false };
// Send to Google Ads if configured and requested
if (send_to_google && config?.google_ads_id && config?.google_ads_conversion_label) {
// In production, you'd make a server-side call to Google Ads API
// or return the data for client-side gtag() call
results.google = true;
await directus.request(
updateItem('conversions', (conversion as any).id, {
sent_to_google: true
})
);
}
// Send to Facebook if configured and requested
if (send_to_facebook && config?.fb_pixel_id && config?.fb_access_token) {
// In production, you'd make a Conversions API call to Facebook
// https://developers.facebook.com/docs/marketing-api/conversions-api
results.facebook = true;
await directus.request(
updateItem('conversions', (conversion as any).id, {
sent_to_facebook: true
})
);
}
return new Response(
JSON.stringify({
success: true,
conversion_id: (conversion as any).id,
sent: results
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error tracking conversion:', error);
return new Response(
JSON.stringify({ error: 'Failed to track conversion' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,61 @@
// @ts-ignore - Astro types available at build time
import type { APIRoute } from 'astro';
import { getDirectusClient, createItem } from '@/lib/directus/client';
/**
* Event Tracking API
*
* Records custom events (form submits, button clicks, scroll depth, etc.)
*
* POST /api/track/event
*/
export const POST: APIRoute = async ({ request }: { request: Request }) => {
try {
const data = await request.json();
const {
site_id,
event_name,
event_category,
event_label,
event_value,
page_path,
session_id,
visitor_id,
metadata
} = data;
if (!site_id || !event_name) {
return new Response(
JSON.stringify({ error: 'site_id and event_name are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
await directus.request(
createItem('events', {
site: site_id,
event_name,
event_category,
event_label,
event_value,
page_path,
session_id,
visitor_id,
metadata
})
);
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error tracking event:', error);
return new Response(
JSON.stringify({ error: 'Failed to track event' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,133 @@
// @ts-ignore - Astro types available at build time
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
/**
* Pageview Tracking API
*
* Records pageviews for internal analytics.
* Detects bots and extracts UTM parameters.
*
* POST /api/track/pageview
*/
// Common bot user agents
const BOT_PATTERNS = [
'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider',
'yandexbot', 'facebookexternalhit', 'twitterbot', 'linkedinbot',
'whatsapp', 'telegrambot', 'applebot', 'semrushbot', 'ahrefsbot',
'mj12bot', 'dotbot', 'petalbot', 'bytespider', 'gptbot', 'claudebot',
'crawler', 'spider', 'bot/', 'bot;', 'headless', 'phantomjs', 'selenium'
];
function detectBot(userAgent: string): { isBot: boolean; botName: string | null } {
const ua = userAgent.toLowerCase();
for (const pattern of BOT_PATTERNS) {
if (ua.includes(pattern)) {
return { isBot: true, botName: pattern };
}
}
return { isBot: false, botName: null };
}
function detectDevice(userAgent: string): string {
const ua = userAgent.toLowerCase();
if (/mobile|android|iphone|ipad|ipod|blackberry|windows phone/i.test(ua)) {
if (/ipad|tablet/i.test(ua)) return 'tablet';
return 'mobile';
}
return 'desktop';
}
function detectBrowser(userAgent: string): string {
if (userAgent.includes('Firefox')) return 'Firefox';
if (userAgent.includes('Edg')) return 'Edge';
if (userAgent.includes('Chrome')) return 'Chrome';
if (userAgent.includes('Safari')) return 'Safari';
if (userAgent.includes('Opera')) return 'Opera';
return 'Unknown';
}
function detectOS(userAgent: string): string {
if (userAgent.includes('Windows')) return 'Windows';
if (userAgent.includes('Mac OS')) return 'macOS';
if (userAgent.includes('Linux')) return 'Linux';
if (userAgent.includes('Android')) return 'Android';
if (userAgent.includes('iOS') || userAgent.includes('iPhone') || userAgent.includes('iPad')) return 'iOS';
return 'Unknown';
}
export const POST: APIRoute = async ({ request }: { request: Request }) => {
try {
const data = await request.json();
const {
site_id,
page_path,
page_title,
referrer,
session_id,
visitor_id
} = data;
if (!site_id || !page_path) {
return new Response(
JSON.stringify({ error: 'site_id and page_path are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Extract user agent and IP
const userAgent = request.headers.get('user-agent') || '';
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.headers.get('x-real-ip') ||
'unknown';
// Detect bot
const { isBot, botName } = detectBot(userAgent);
// Parse URL for UTM params
const url = new URL(page_path, 'http://localhost');
const utmSource = url.searchParams.get('utm_source');
const utmMedium = url.searchParams.get('utm_medium');
const utmCampaign = url.searchParams.get('utm_campaign');
const utmContent = url.searchParams.get('utm_content');
const utmTerm = url.searchParams.get('utm_term');
const directus = getDirectusClient();
// Create pageview record
await directus.request(
createItem('pageviews', {
site: site_id,
page_path: url.pathname,
page_title,
referrer,
utm_source: utmSource,
utm_medium: utmMedium,
utm_campaign: utmCampaign,
utm_content: utmContent,
utm_term: utmTerm,
user_agent: userAgent.substring(0, 500),
ip_address: ip,
device_type: detectDevice(userAgent),
browser: detectBrowser(userAgent),
os: detectOS(userAgent),
session_id,
visitor_id,
is_bot: isBot,
bot_name: botName
})
);
return new Response(
JSON.stringify({ success: true, is_bot: isBot }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error tracking pageview:', error);
return new Response(
JSON.stringify({ error: 'Failed to track pageview' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};