diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..ab04953 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,56 @@ +import { PLAUSIBLE_DOMAIN as PUBLIC_DOMAIN, PLAUSIBLE_EVENT_ENDPOINT } from '$lib/plausible-config.js'; + + +type TrackProps = Record; + +function getEnv(name: string, fallback: string = ''): string { + const v = process.env[name]; + return typeof v === 'string' && v.trim() !== '' ? v : fallback; +} + +function buildEventUrl(pathname: string): string { + const siteBase = 'https://pypistats.dev'; + return `${siteBase}${pathname}`; +} + +export function trackApiEvent( + name: string, + pathname: string, + props: TrackProps = {}, + requestHeaders?: Headers +): void { + const endpoint = PLAUSIBLE_EVENT_ENDPOINT; + const domain = PUBLIC_DOMAIN; + if (!domain) return; + + const url = endpoint; + const body = { + name, + url: buildEventUrl(pathname), + domain, + props + } as any; + + const headers: Record = { + 'Content-Type': 'application/json' + }; + + const apiKey = getEnv('PLAUSIBLE_API_KEY'); + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + + const ua = requestHeaders?.get('user-agent'); + if (ua) headers['User-Agent'] = ua; + + const ip = + requestHeaders?.get('x-forwarded-for') || + requestHeaders?.get('x-real-ip') || + ''; + if (ip) headers['X-Forwarded-For'] = ip; + + // Fire-and-forget; never throw or await + void fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }) + .then(() => {}) + .catch(() => {}); +} + + diff --git a/src/lib/plausible-config.ts b/src/lib/plausible-config.ts new file mode 100644 index 0000000..3f5ac82 --- /dev/null +++ b/src/lib/plausible-config.ts @@ -0,0 +1,15 @@ +// Hardcoded Plausible configuration for public usage + +// Self-hosted Plausible server base +export const PLAUSIBLE_BASE = 'https://events.plygrnd.org'; + +// Standard event endpoint derived from the unified base +export const PLAUSIBLE_EVENT_ENDPOINT = `${PLAUSIBLE_BASE}/api/event`; + +// Public domain/site ID for this app +export const PLAUSIBLE_DOMAIN = 'pypistats.dev'; + +// Dev capture toggle (public) — default false; override via Vite define if needed +export const PLAUSIBLE_CAPTURE_LOCALHOST = false; + + diff --git a/src/routes/api/packages/[package]/chart/[type]/+server.ts b/src/routes/api/packages/[package]/chart/[type]/+server.ts index a7c52ba..3106dda 100644 --- a/src/routes/api/packages/[package]/chart/[type]/+server.ts +++ b/src/routes/api/packages/[package]/chart/[type]/+server.ts @@ -3,13 +3,14 @@ import { dev } from '$app/environment'; import { CacheManager } from '$lib/redis.js'; import { getOverallDownloads, getPythonMajorDownloads, getPythonMinorDownloads, getSystemDownloads, getInstallerDownloads, getVersionDownloads } from '$lib/api.js'; import { ChartJSNodeCanvas } from 'chartjs-node-canvas'; +import { trackApiEvent } from '$lib/analytics.js'; const cache = new CacheManager(); const width = 1200; const height = 600; -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const type = params.type || 'overall'; const chartType = (url.searchParams.get('chart') || 'line').toLowerCase(); // 'line' | 'bar' @@ -102,6 +103,13 @@ export const GET: RequestHandler = async ({ params, url }) => { labels, datasets }; + trackApiEvent('api_chart', `/api/packages/${encodeURIComponent(packageName)}/chart/${type}`, { + package: packageName, + type, + chart: chartType, + format: 'json', + ok: true + }, request.headers); return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' } }); } @@ -140,9 +148,22 @@ export const GET: RequestHandler = async ({ params, url }) => { await cache.set(cacheKey, image.toString('base64'), 3600); } + trackApiEvent('api_chart', `/api/packages/${encodeURIComponent(packageName)}/chart/${type}`, { + package: packageName, + type, + chart: chartType, + format: 'png', + ok: true + }, request.headers); return new Response(image, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' } }); } catch (error) { console.error('Error rendering chart:', error); + trackApiEvent('api_chart', `/api/packages/${encodeURIComponent(packageName)}/chart/${type}`, { + package: packageName, + type, + chart: chartType, + ok: false + }, request.headers); return new Response('Internal server error', { status: 500 }); } }; diff --git a/src/routes/api/packages/[package]/installer/+server.ts b/src/routes/api/packages/[package]/installer/+server.ts index 1b65485..395e569 100644 --- a/src/routes/api/packages/[package]/installer/+server.ts +++ b/src/routes/api/packages/[package]/installer/+server.ts @@ -2,8 +2,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { prisma } from '$lib/prisma.js'; import { DataProcessor } from '$lib/data-processor.js'; +import { trackApiEvent } from '$lib/analytics.js'; -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, request }) => { const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; if (!packageName || packageName === '__all__') { return json({ error: 'Invalid package name' }, { status: 400 }); @@ -23,9 +24,17 @@ export const GET: RequestHandler = async ({ params }) => { type: 'installer_downloads', data: rows.map(r => ({ date: r.date, category: r.category, downloads: r.downloads })) }; + trackApiEvent('api_installer', `/api/packages/${encodeURIComponent(packageName)}/installer`, { + package: packageName, + ok: true + }, request.headers); return json(response); } catch (error) { console.error('Error fetching installer downloads:', error); + trackApiEvent('api_installer', `/api/packages/${encodeURIComponent(packageName)}/installer`, { + package: packageName, + ok: false + }, request.headers); return json({ error: 'Internal server error' }, { status: 500 }); } }; diff --git a/src/routes/api/packages/[package]/overall/+server.ts b/src/routes/api/packages/[package]/overall/+server.ts index 6739a48..7580bf8 100644 --- a/src/routes/api/packages/[package]/overall/+server.ts +++ b/src/routes/api/packages/[package]/overall/+server.ts @@ -1,8 +1,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getOverallDownloads } from '$lib/api.js'; +import { trackApiEvent } from '$lib/analytics.js'; -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const mirrors = url.searchParams.get('mirrors'); @@ -27,9 +28,19 @@ export const GET: RequestHandler = async ({ params, url }) => { })) }; + trackApiEvent('api_overall', `/api/packages/${encodeURIComponent(packageName)}/overall`, { + package: packageName, + mirrors: String(mirrors ?? ''), + ok: true + }, request.headers); return json(response); } catch (error) { console.error('Error fetching overall downloads:', error); + trackApiEvent('api_overall', `/api/packages/${encodeURIComponent(packageName)}/overall`, { + package: packageName, + mirrors: String(mirrors ?? ''), + ok: false + }, request.headers); return json({ error: 'Internal server error' }, { status: 500 }); } }; \ No newline at end of file diff --git a/src/routes/api/packages/[package]/python_major/+server.ts b/src/routes/api/packages/[package]/python_major/+server.ts index 91d0a39..166d05c 100644 --- a/src/routes/api/packages/[package]/python_major/+server.ts +++ b/src/routes/api/packages/[package]/python_major/+server.ts @@ -1,8 +1,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getPythonMajorDownloads } from '$lib/api.js'; +import { trackApiEvent } from '$lib/analytics.js'; -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const version = url.searchParams.get('version'); @@ -26,10 +27,19 @@ export const GET: RequestHandler = async ({ params, url }) => { downloads: r.downloads })) }; - + trackApiEvent('api_python_major', `/api/packages/${encodeURIComponent(packageName)}/python_major`, { + package: packageName, + version: String(version ?? ''), + ok: true + }, request.headers); return json(response); } catch (error) { console.error('Error fetching Python major downloads:', error); + trackApiEvent('api_python_major', `/api/packages/${encodeURIComponent(packageName)}/python_major`, { + package: packageName, + version: String(version ?? ''), + ok: false + }, request.headers); return json({ error: 'Internal server error' }, { status: 500 }); } }; \ No newline at end of file diff --git a/src/routes/api/packages/[package]/python_minor/+server.ts b/src/routes/api/packages/[package]/python_minor/+server.ts index cddb8b3..cdefe93 100644 --- a/src/routes/api/packages/[package]/python_minor/+server.ts +++ b/src/routes/api/packages/[package]/python_minor/+server.ts @@ -1,8 +1,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getPythonMinorDownloads } from '$lib/api.js'; +import { trackApiEvent } from '$lib/analytics.js'; -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const version = url.searchParams.get('version'); @@ -26,10 +27,19 @@ export const GET: RequestHandler = async ({ params, url }) => { downloads: r.downloads })) }; - + trackApiEvent('api_python_minor', `/api/packages/${encodeURIComponent(packageName)}/python_minor`, { + package: packageName, + version: String(version ?? ''), + ok: true + }, request.headers); return json(response); } catch (error) { console.error('Error fetching Python minor downloads:', error); + trackApiEvent('api_python_minor', `/api/packages/${encodeURIComponent(packageName)}/python_minor`, { + package: packageName, + version: String(version ?? ''), + ok: false + }, request.headers); return json({ error: 'Internal server error' }, { status: 500 }); } }; \ No newline at end of file diff --git a/src/routes/api/packages/[package]/recent/+server.ts b/src/routes/api/packages/[package]/recent/+server.ts index 1c65ab5..bcafff4 100644 --- a/src/routes/api/packages/[package]/recent/+server.ts +++ b/src/routes/api/packages/[package]/recent/+server.ts @@ -4,6 +4,7 @@ import { getRecentDownloads } from '$lib/api.js'; import { RECENT_CATEGORIES } from '$lib/database.js'; import { RateLimiter } from '$lib/redis.js'; import { DataProcessor } from '$lib/data-processor.js'; +import { trackApiEvent } from '$lib/analytics.js'; const rateLimiter = new RateLimiter(); @@ -62,9 +63,19 @@ export const GET: RequestHandler = async ({ params, url, request }) => { 'X-RateLimit-Reset': (Math.floor(Date.now() / 1000) + 3600).toString() }; + trackApiEvent('api_recent', `/api/packages/${encodeURIComponent(packageName)}/recent`, { + package: packageName, + period: String(category ?? ''), + ok: true + }, request.headers); return json(response, { headers }); } catch (error) { console.error('Error fetching recent downloads:', error); + trackApiEvent('api_recent', `/api/packages/${encodeURIComponent(packageName)}/recent`, { + package: packageName, + period: String(category ?? ''), + ok: false + }, request.headers); return json({ error: 'Internal server error' }, { status: 500 }); } }; \ No newline at end of file diff --git a/src/routes/api/packages/[package]/summary/+server.ts b/src/routes/api/packages/[package]/summary/+server.ts index d70bc86..e522dd9 100644 --- a/src/routes/api/packages/[package]/summary/+server.ts +++ b/src/routes/api/packages/[package]/summary/+server.ts @@ -2,8 +2,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { prisma } from '$lib/prisma.js'; import { DataProcessor } from '$lib/data-processor.js'; +import { trackApiEvent } from '$lib/analytics.js'; -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, request }) => { const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; if (!packageName || packageName === '__all__') { return json({ error: 'Invalid package name' }, { status: 400 }); @@ -41,6 +42,10 @@ export const GET: RequestHandler = async ({ params }) => { const pythonMajorTotals = Object.fromEntries(pyMajorAll.map(r => [r.category, Number(r._sum.downloads || 0)])); const pythonMinorTotals = Object.fromEntries(pyMinorAll.map(r => [r.category, Number(r._sum.downloads || 0)])); + trackApiEvent('api_summary', `/api/packages/${encodeURIComponent(packageName)}/summary`, { + package: packageName, + ok: true + }, request.headers); return json({ package: packageName, type: 'summary', @@ -53,6 +58,10 @@ export const GET: RequestHandler = async ({ params }) => { }); } catch (error) { console.error('Error building package summary:', error); + trackApiEvent('api_summary', `/api/packages/${encodeURIComponent(packageName)}/summary`, { + package: packageName, + ok: false + }, request.headers); return json({ error: 'Internal server error' }, { status: 500 }); } }; diff --git a/src/routes/api/packages/[package]/system/+server.ts b/src/routes/api/packages/[package]/system/+server.ts index c404c61..33f54c6 100644 --- a/src/routes/api/packages/[package]/system/+server.ts +++ b/src/routes/api/packages/[package]/system/+server.ts @@ -1,8 +1,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getSystemDownloads } from '$lib/api.js'; +import { trackApiEvent } from '$lib/analytics.js'; -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, request }) => { const packageName = params.package?.replace(/\./g, '-').replace(/_/g, '-') || ''; const os = url.searchParams.get('os'); @@ -26,10 +27,19 @@ export const GET: RequestHandler = async ({ params, url }) => { downloads: r.downloads })) }; - + trackApiEvent('api_system', `/api/packages/${encodeURIComponent(packageName)}/system`, { + package: packageName, + os: String(os ?? ''), + ok: true + }, request.headers); return json(response); } catch (error) { console.error('Error fetching system downloads:', error); + trackApiEvent('api_system', `/api/packages/${encodeURIComponent(packageName)}/system`, { + package: packageName, + os: String(os ?? ''), + ok: false + }, request.headers); return json({ error: 'Internal server error' }, { status: 500 }); } }; \ No newline at end of file diff --git a/src/routes/packages/[package]/+page.svelte b/src/routes/packages/[package]/+page.svelte index 0c07536..674aa1a 100644 --- a/src/routes/packages/[package]/+page.svelte +++ b/src/routes/packages/[package]/+page.svelte @@ -1,6 +1,8 @@