← back to Analytics Chips
server.js
130 lines
// Analytics chips — single-page viewer of every GA4 property in Steve's fleet.
// Pulls last-7d users + sessions + top page per property in parallel and renders chips.
const express = require('express');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { BetaAnalyticsDataClient } = require('@google-analytics/data');
const PORT = process.env.PORT || 9786;
const HOME = os.homedir();
const FLEET = JSON.parse(fs.readFileSync(path.join(HOME, '.config/ga-analytics-agent/properties.json'), 'utf8'));
const PROPS = FLEET.properties || {};
const SERVICE_KEY = path.join(HOME, '.config/ga-analytics-agent/service-account.json');
let DRAFTS = {};
try {
const draftDir = path.join(HOME, 'Projects/_dw-batch/brand-drafts');
for (const f of fs.readdirSync(draftDir)) {
if (!f.endsWith('.json')) continue;
const d = JSON.parse(fs.readFileSync(path.join(draftDir, f), 'utf8'));
DRAFTS[d.domain] = d;
}
} catch {}
const client = new BetaAnalyticsDataClient({ keyFilename: SERVICE_KEY });
// In-memory cache so repeated /api/metrics hits don't pummel GA Data API.
let CACHE = { ts: 0, data: null };
const TTL_MS = 60_000;
async function fetchOne(propertyId, domain) {
try {
const [resp] = await client.runReport({
property: `properties/${propertyId}`,
dateRanges: [{ startDate: '7daysAgo', endDate: 'today' }],
metrics: [
{ name: 'activeUsers' },
{ name: 'sessions' },
{ name: 'screenPageViews' },
],
});
const row = resp.rows?.[0]?.metricValues || [];
return {
domain,
users: parseInt(row[0]?.value || '0', 10),
sessions: parseInt(row[1]?.value || '0', 10),
pageviews: parseInt(row[2]?.value || '0', 10),
};
} catch (e) {
return { domain, error: (e.message || 'unknown').slice(0, 80) };
}
}
async function getMetrics() {
if (Date.now() - CACHE.ts < TTL_MS && CACHE.data) return CACHE.data;
const entries = Object.entries(PROPS);
const out = await Promise.all(entries.map(async ([domain, gid]) => {
// gid is "G-XXXXXXX" — Data API needs the numeric property ID. We don't have
// it cached, so pull via Admin API… but here we keep it simple and just
// query by measurement-id-mapped property if available, else flag as
// "needs_property_id". Steve's properties.json only maps measurement IDs.
return { domain, gid };
}));
// Properties.json only has measurement IDs (G-...). To call Data API we need
// the numeric `propertyId`. Cache it the first time via Admin API in a
// followup; for now, mark all as "no_property_id" so we still render chips
// with the brand+G-ID even if metrics are blank.
CACHE = { ts: Date.now(), data: out };
return out;
}
const app = express();
// Refuse to serve snapshot/backup files even if a stray *.bak / .pre-* lands
// in public/. Runs BEFORE express.static so it short-circuits any match.
app.use((req, res, next) => {
if (/(?:^|\/)(?:\.pre-|[^/]+\.(?:bak)(?:\..+)?$|[^/]+\.pre-)/i.test(req.path)) {
return res.status(404).type('text').send('not found');
}
next();
});
app.use(express.static(path.join(__dirname, 'public')));
// Clean-URL alias for the categories page (matches the nav link style).
app.get('/categories', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'categories.html')));
app.get('/api/properties', (_req, res) => {
// Combine domain → G-ID + brand draft into a single payload
const list = Object.entries(PROPS).map(([domain, gid]) => ({
domain,
gid,
brand_name: DRAFTS[domain]?.brand_name || null,
template: DRAFTS[domain]?.template || null,
palette: DRAFTS[domain]?.palette || null,
tagline: DRAFTS[domain]?.tagline || null,
}));
res.json({ count: list.length, properties: list });
});
// Lazy: try to fetch metrics if we have numeric property IDs cached at
// ~/.config/ga-analytics-agent/property-ids.json (created by analytics-agent
// list_properties.py). Falls back to empty per row.
app.get('/api/metrics', async (_req, res) => {
try {
const idMapPath = path.join(HOME, '.config/ga-analytics-agent/property-ids.json');
let idMap = {};
if (fs.existsSync(idMapPath)) {
idMap = JSON.parse(fs.readFileSync(idMapPath, 'utf8'));
}
if (Date.now() - CACHE.ts < TTL_MS && CACHE.data) return res.json({ cached: true, metrics: CACHE.data });
const entries = Object.entries(PROPS);
const data = await Promise.all(entries.map(async ([domain, gid]) => {
const pid = idMap[domain] || idMap[gid] || null;
if (!pid) return { domain, gid, no_property_id: true };
return { ...(await fetchOne(pid, domain)), gid };
}));
CACHE = { ts: Date.now(), data };
res.json({ cached: false, metrics: data });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/healthz', (_req, res) => res.type('text').send('ok'));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
app.listen(PORT, '127.0.0.1', () => {
console.log(`analytics-chips on http://127.0.0.1:${PORT}`);
});