Add analytics tracking to API endpoints for improved event monitoring. Updated GET handlers across various routes to include tracking of successful and failed requests, enhancing data collection for package usage insights.

This commit is contained in:
Luke Hagar
2025-08-29 15:46:54 -05:00
parent 8d6c5c67a2
commit 128b923760
12 changed files with 240 additions and 23 deletions

56
src/lib/analytics.ts Normal file
View File

@@ -0,0 +1,56 @@
import { PLAUSIBLE_DOMAIN as PUBLIC_DOMAIN, PLAUSIBLE_EVENT_ENDPOINT } from '$lib/plausible-config.js';
type TrackProps = Record<string, string | number | boolean | null | undefined>;
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<string, string> = {
'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(() => {});
}

View File

@@ -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;

View File

@@ -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 });
}
};

View File

@@ -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 });
}
};

View File

@@ -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 });
}
};

View File

@@ -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 });
}
};

View File

@@ -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 });
}
};

View File

@@ -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 });
}
};

View File

@@ -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 });
}
};

View File

@@ -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 });
}
};

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import type { PageData } from './$types';
import { onMount, onDestroy } from 'svelte';
import { afterNavigate } from '$app/navigation';
import { PLAUSIBLE_DOMAIN, PLAUSIBLE_EVENT_ENDPOINT, PLAUSIBLE_CAPTURE_LOCALHOST } from '$lib/plausible-config.js';
import LoadingSpinner from '$lib/components/LoadingSpinner.svelte';
import { MetaTags } from 'svelte-meta-tags';
const { data }: { data: PageData } = $props();
@@ -12,6 +14,37 @@
let charts: any[] = [];
let installerCanvas: HTMLCanvasElement | null = $state(null);
// Plausible: track package views
let lastTrackedPackage: string = '';
let plausibleInitDone: boolean = false;
let plausibleTrack: ((name: string, options?: any) => void) | null = null;
async function ensurePlausible() {
if (plausibleInitDone && plausibleTrack) return;
if (typeof window === 'undefined') return;
const mod = await import('@plausible-analytics/tracker');
const domain = PLAUSIBLE_DOMAIN || window.location.hostname;
const endpoint = PLAUSIBLE_EVENT_ENDPOINT || undefined;
mod.init({
domain,
endpoint,
captureOnLocalhost: PLAUSIBLE_CAPTURE_LOCALHOST,
logging: false,
bindToWindow: false
});
plausibleTrack = mod.track;
plausibleInitDone = true;
}
async function trackPackageView(pkg: string) {
if (!pkg || lastTrackedPackage === pkg) return;
if (typeof window === 'undefined') return;
await ensurePlausible();
if (!plausibleTrack) return;
plausibleTrack('package_view', { props: { package: pkg } });
lastTrackedPackage = pkg;
}
const numberFormatter = new Intl.NumberFormat(undefined);
const compactFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact',
@@ -138,6 +171,9 @@
}
onMount(() => {
// Track initial view
if (data?.packageName) trackPackageView(data.packageName);
loadAndRenderChart(overallCanvas, 'overall');
requestAnimationFrame(() => {
loadAndRenderChart(pyMajorCanvas, 'python_major');
@@ -147,6 +183,19 @@
});
});
afterNavigate((nav) => {
let url: URL;
const maybe = (nav as any);
if (maybe && maybe.to && maybe.to.url instanceof URL) {
url = maybe.to.url as URL;
} else {
url = new URL(window.location.href);
}
const match = url.pathname.match(/^\/packages\/([^\/]+)/);
const pkg = match?.[1] ? decodeURIComponent(match[1]) : '';
if (pkg) void trackPackageView(pkg);
});
onDestroy(() => {
for (const c of charts) {
try {
@@ -241,14 +290,14 @@
href={meta.pypiUrl}
rel="noopener"
target="_blank">View on PyPI</a
>
>
{#if meta.homePage}
<a
class="inline-flex items-center rounded-full border border-gray-700 bg-gray-900 px-2.5 py-1 text-gray-300 hover:bg-gray-800"
href={meta.homePage}
rel="noopener"
target="_blank">Homepage</a
>
>
{/if}
{#if meta.projectUrls}
{#each Object.entries(meta.projectUrls).filter(([label, url]) => !['homepage'].includes(label.toLowerCase())) as [label, url]}
@@ -258,7 +307,7 @@
href={url}
rel="noopener"
target="_blank">{label}</a
>
>
{/if}
{/each}
{/if}
@@ -284,11 +333,11 @@
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>Period</th
>
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase"
>Downloads</th
>
>
</tr>
</thead>
<tbody class="divide-y divide-gray-800 bg-gray-950">
@@ -322,11 +371,11 @@
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>Category</th
>
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase"
>Downloads</th
>
>
</tr>
</thead>
<tbody class="divide-y divide-gray-800 bg-gray-950">
@@ -362,11 +411,11 @@
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>System</th
>
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase"
>Downloads</th
>
>
</tr>
</thead>
<tbody class="divide-y divide-gray-800 bg-gray-950">
@@ -402,11 +451,11 @@
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
>Version</th
>
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-400 uppercase"
>Downloads</th
>
>
</tr>
</thead>
<tbody class="divide-y divide-gray-800 bg-gray-950">
@@ -424,7 +473,7 @@
</td>
<td class="px-6 py-3 text-right text-sm font-semibold text-gray-100"
>{formatNumber(row.downloads)}</td
>
>
</tr>
{/each}
{:catch _}
@@ -545,7 +594,7 @@
href={endpointUrl(ep.path)}
rel="noopener"
target="_blank">{endpointUrl(ep.path)}</a
>
>
</div>
<button
class="ml-3 shrink-0 rounded-md border border-blue-700 bg-blue-800 px-2 py-1 text-xs text-white hover:bg-blue-700"

6
src/types/plausible-tracker.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module '@plausible-analytics/tracker' {
export function init(options: any): void;
export const track: (name: string, options?: any) => void;
}