Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 8x 10x 10x 1x 9x 9x 1x 1x 8x 8x 8x 8x 6x 6x 3x 2x 2x 1x 1x 3x 3x 2x 2x 2x | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import { BetaAnalyticsDataClient } from '@google-analytics/data';
import allowCors from '../../helpers/cors';
import { isSafePagePath } from '../../helpers/slug';
import { CACHE } from '@/helpers/http';
/**
* @description This function is used to get the total number of page views for a given page. It uses the Google
* Analytics Data API to get the data.
*
* @param {NextApiRequest} req
* @param {NextApiResponse<Data>} res
* @returns {Promise<{
* error?: string;
* total?: number;
* }>}
* @throws {Error: Error while parsing analytics data}
* @see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
*/
interface AnalyticsData {
pageViews: string | number;
newUsers: string | number;
}
type AnalyticsResponse = AnalyticsData | { error: string };
const GA_PROPERTY = 'properties/348472560';
// Since the property started collecting, so the counters read as all-time totals.
const GA_DATE_RANGES = [{ startDate: '2023-01-01', endDate: 'today' }];
const GA_METRICS = [{ name: 'screenPageViews' }, { name: 'newUsers' }];
const EMPTY_ANALYTICS: AnalyticsData = { pageViews: 0, newUsers: 0 };
/**
* The GA4 Data API bills every call against a per-property daily token quota, and
* without this header each visitor rendering a view counter spent one. A total
* that is five minutes stale is indistinguishable from a fresh one to a reader,
* so the edge answers instead — and `stale-while-revalidate` means a quota
* exhaustion or an API outage shows the last known figure rather than an error.
*
* SDD-L02 moved the value itself into helpers/http.ts. This route was the only one that had a cache
* header; the other seven now share that module, and a local copy here would be the start of the
* next drift.
*/
const CACHE_CONTROL = CACHE.analytics;
/**
* @description Build the runReport request. Without a slug the report covers the whole
* property; with one it is filtered down to that exact page path.
*/
const buildReportRequest = (slug?: string) => ({
property: GA_PROPERTY,
dateRanges: GA_DATE_RANGES,
metrics: GA_METRICS,
...(slug && {
dimensionFilter: {
filter: {
fieldName: 'pagePath',
stringFilter: { matchType: 'EXACT' as const, value: slug },
},
},
}),
});
export default allowCors(async function handler(req: NextApiRequest, res: NextApiResponse<AnalyticsResponse>) {
// Only allow GET requests
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { slug } = req.query;
// Validate slug parameter if provided
if (slug && !isSafePagePath(slug)) {
res.setHeader('Cache-Control', CACHE.error);
return res.status(400).json({ error: 'Invalid slug parameter' });
}
// Validate required environment variables
Iif (
!process.env.ANALYTICS_CLIENT_EMAIL ||
!process.env.ANALYTICS_PRIVATE_KEY ||
!process.env.ANALYTICS_PROJECT_ID
) {
console.error('Missing required environment variables for Google Analytics API');
res.setHeader('Cache-Control', CACHE.error);
return res.status(500).json({ error: 'Configuration error' });
}
try {
const analyticsDataClient = new BetaAnalyticsDataClient({
credentials: {
client_email: process.env.ANALYTICS_CLIENT_EMAIL,
private_key: process.env.ANALYTICS_PRIVATE_KEY?.replace(/\\n/g, '\n'),
},
projectId: process.env.ANALYTICS_PROJECT_ID,
});
const [response] = await analyticsDataClient.runReport(buildReportRequest(slug as string | undefined));
const [row] = response?.rows ?? [];
if (!row) {
// A page nobody has visited yet is a normal result, not a failure — GA simply
// returns no rows for it. Only the unfiltered site-wide report having no rows
// means something is actually wrong.
if (slug) {
res.setHeader('Cache-Control', CACHE_CONTROL);
return res.status(200).json(EMPTY_ANALYTICS);
}
// Errors stay uncached: caching one would keep serving it for five minutes
// after the cause is gone.
res.setHeader('Cache-Control', CACHE.error);
return res.status(500).json({ error: 'No data' });
}
res.setHeader('Cache-Control', CACHE_CONTROL);
return res.status(200).json({
pageViews: row.metricValues?.[0]?.value || '0',
newUsers: row.metricValues?.[1]?.value || '0',
});
} catch (err: unknown) {
console.error('Analytics API Error:', err);
res.setHeader('Cache-Control', CACHE.error);
return res.status(500).json({ error: 'Internal server error' });
}
});
|