Fix private analytics aggregation

This commit is contained in:
Marc Froehlich
2026-09-14 22:40:53 +02:00
parent a3f3bf8872
commit 89422d18ad
3 changed files with 171 additions and 41 deletions
+103 -36
View File
@@ -76,8 +76,8 @@ function parseAnalyticsLine(line, timeZone = "Europe/Berlin") {
const status = Number(fields[6]); const status = Number(fields[6]);
const bytes = fields[7] === "-" ? 0 : Number(fields[7]); const bytes = fields[7] === "-" ? 0 : Number(fields[7]);
const local = localParts(fields[2], timeZone); const local = localParts(fields[2], timeZone);
const requestPath = normalizePath(fields[4]); const requestPath = fields[4] === "" ? null : normalizePath(fields[4]);
if (!local || !requestPath || !/^\d{3}$/.test(fields[6]) if (!local || (fields[4] !== "" && !requestPath) || !/^\d{3}$/.test(fields[6])
|| !Number.isSafeInteger(bytes) || bytes < 0) return null; || !Number.isSafeInteger(bytes) || bytes < 0) return null;
let userAgent = fields[8]; let userAgent = fields[8];
if (userAgent.startsWith("\"") && userAgent.endsWith("\"")) { if (userAgent.startsWith("\"") && userAgent.endsWith("\"")) {
@@ -157,7 +157,7 @@ function isKnownBot(userAgent) {
} }
function isEligibleWebsiteRequest(record) { function isEligibleWebsiteRequest(record) {
if (record.method !== "GET" || isKnownBot(record.userAgent)) return false; if (record.method !== "GET" || !record.path || isKnownBot(record.userAgent)) return false;
if (["/visitor-count.json", UPDATE_INFO_PATH, "/sitemap.xml", "/robots.txt", "/favicon.ico", if (["/visitor-count.json", UPDATE_INFO_PATH, "/sitemap.xml", "/robots.txt", "/favicon.ico",
"/assets/favicon.svg", "/health", "/healthz", "/ping", "/status"].includes(record.path)) { "/assets/favicon.svg", "/health", "/healthz", "/ping", "/status"].includes(record.path)) {
return false; return false;
@@ -270,17 +270,25 @@ function sumValues(values) {
} }
function aggregateGoAccessReport(report, kind) { function aggregateGoAccessReport(report, kind) {
const total = report && report.general && report.general.total_requests; const validRequests = report && report.general && report.general.valid_requests;
if (!Number.isSafeInteger(total) || total < 0) throw new Error("GoAccess report contains an invalid total request count"); if (!Number.isSafeInteger(validRequests) || validRequests < 0) {
throw new Error("GoAccess report contains an invalid valid-request count");
}
const paths = countPanel(report, "requests");
const total = sumValues(paths);
if (validRequests !== total) {
throw new Error("GoAccess valid-request count differs from the request-panel definition");
}
const countries = countCountries(report); const countries = countCountries(report);
const countryTotal = sumValues(countries); const countryTotal = sumValues(countries);
if (countryTotal > total) throw new Error("GoAccess country total exceeds the request total"); if (countryTotal > total) throw new Error("GoAccess country total exceeds the request total");
if (countryTotal < total) addCount(countries, UNKNOWN_COUNTRY, total - countryTotal); if (countryTotal < total) addCount(countries, UNKNOWN_COUNTRY, total - countryTotal);
if (kind === "updateInfo") return { requests: total, countries }; if (kind === "updateInfo") {
const paths = countPanel(report, "requests"); if (Object.keys(paths).some(requestPath => requestPath !== UPDATE_INFO_PATH)) {
if (sumValues(paths) !== total) { throw new Error("update-information report contains an unexpected request path");
throw new Error("website page total differs from the GoAccess request-panel definition"); }
return { requests: total, countries };
} }
return { pageViews: total, countries, paths }; return { pageViews: total, countries, paths };
} }
@@ -299,6 +307,76 @@ function renderMetricsConfig(template, dbPath, geoIpCountryDatabase, includeCraw
return rendered; return rendered;
} }
function runMetricGoAccess({ records, jobId, metricKind, site, context, includeCrawlers }) {
const jobDirectory = path.join(context.runDirectory, jobId);
const dbPath = path.join(jobDirectory, "db");
const inputPath = path.join(jobDirectory, "input.log");
const outputJson = path.join(jobDirectory, "report.json");
const runConfig = path.join(jobDirectory, "goaccess.conf");
fs.mkdirSync(dbPath, { recursive: true });
fs.writeFileSync(inputPath, `${records.map(record => record.line).join("\n")}\n`, { mode: 0o600 });
fs.writeFileSync(runConfig, renderMetricsConfig(
context.configTemplate,
dbPath,
context.registry.geoIpCountryDatabase,
includeCrawlers
), { mode: 0o600 });
context.runGoAccess({
binary: context.goaccessBinary,
args: [inputPath, "--no-global-config", "--config-file", runConfig, "--output", outputJson],
id: jobId,
metricKind,
date: records[0].date,
outputJson,
dbPath
});
try {
return JSON.parse(fs.readFileSync(outputJson, "utf8"));
} catch (error) {
throw new Error(`private metric GoAccess JSON is invalid for ${site.id}/${records[0].date}: ${error.message}`);
}
}
function filterWebsiteRecordsWithGoAccess(records, date, site, context) {
const representatives = [];
const pathByUserAgent = new Map();
for (const record of records) {
if (pathByUserAgent.has(record.userAgent)) continue;
const classifierPath = `/__client/${pathByUserAgent.size}`;
pathByUserAgent.set(record.userAgent, classifierPath);
const representative = {
...record,
ip: "192.0.2.1",
method: "GET",
path: classifierPath,
protocol: "HTTP/1.1",
status: 200,
bytes: 0
};
representative.line = toAnalyticsLine(representative);
representatives.push(representative);
}
const report = runMetricGoAccess({
records: representatives,
jobId: `metrics-${site.id}-website-classifier-${date}`,
metricKind: "websiteClassifier",
site,
context,
includeCrawlers: false
});
const acceptedPaths = countPanel(report, "requests");
const knownPaths = new Set(pathByUserAgent.values());
for (const [classifierPath, count] of Object.entries(acceptedPaths)) {
if (!knownPaths.has(classifierPath) || count !== 1) {
throw new Error(`website client classification is invalid for ${site.id}/${date}`);
}
}
const acceptedUserAgents = new Set([...pathByUserAgent]
.filter(([_userAgent, classifierPath]) => Object.hasOwn(acceptedPaths, classifierPath))
.map(([userAgent]) => userAgent));
return records.filter(record => acceptedUserAgents.has(record.userAgent));
}
function aggregateDays({ records, dates, kind, site, context }) { function aggregateDays({ records, dates, kind, site, context }) {
const byDate = new Map(dates.map(date => [date, []])); const byDate = new Map(dates.map(date => [date, []]));
for (const record of records) { for (const record of records) {
@@ -306,7 +384,16 @@ function aggregateDays({ records, dates, kind, site, context }) {
} }
const daily = {}; const daily = {};
for (const date of dates) { for (const date of dates) {
const dayRecords = byDate.get(date); let dayRecords = byDate.get(date);
if (dayRecords.length === 0) {
daily[date] = kind === "website"
? { pageViews: 0, countries: {}, paths: {} }
: { requests: 0, countries: {}, hours: {}, clients: {} };
continue;
}
if (kind === "website") {
dayRecords = filterWebsiteRecordsWithGoAccess(dayRecords, date, site, context);
}
if (dayRecords.length === 0) { if (dayRecords.length === 0) {
daily[date] = kind === "website" daily[date] = kind === "website"
? { pageViews: 0, countries: {}, paths: {} } ? { pageViews: 0, countries: {}, paths: {} }
@@ -314,34 +401,14 @@ function aggregateDays({ records, dates, kind, site, context }) {
continue; continue;
} }
const jobId = `metrics-${site.id}-${kind}-${date}`; const jobId = `metrics-${site.id}-${kind}-${date}`;
const jobDirectory = path.join(context.runDirectory, jobId); const report = runMetricGoAccess({
const dbPath = path.join(jobDirectory, "db"); records: dayRecords,
const inputPath = path.join(jobDirectory, "input.log"); jobId,
const outputJson = path.join(jobDirectory, "report.json");
const runConfig = path.join(jobDirectory, "goaccess.conf");
fs.mkdirSync(dbPath, { recursive: true });
fs.writeFileSync(inputPath, `${dayRecords.map(record => record.line).join("\n")}\n`, { mode: 0o600 });
fs.writeFileSync(runConfig, renderMetricsConfig(
context.configTemplate,
dbPath,
context.registry.geoIpCountryDatabase,
kind === "updateInfo"
), { mode: 0o600 });
context.runGoAccess({
binary: context.goaccessBinary,
args: [inputPath, "--no-global-config", "--config-file", runConfig, "--output", outputJson],
id: jobId,
metricKind: kind, metricKind: kind,
date, site,
outputJson, context,
dbPath includeCrawlers: true
}); });
let report;
try {
report = JSON.parse(fs.readFileSync(outputJson, "utf8"));
} catch (error) {
throw new Error(`private metric GoAccess JSON is invalid for ${site.id}/${date}: ${error.message}`);
}
daily[date] = aggregateGoAccessReport(report, kind); daily[date] = aggregateGoAccessReport(report, kind);
if (kind === "updateInfo") { if (kind === "updateInfo") {
const hours = {}; const hours = {};
+42 -2
View File
@@ -150,14 +150,18 @@ function fakeGoAccess(reports, calls, failureId) {
throw new Error("simulated GoAccess failure"); throw new Error("simulated GoAccess failure");
} }
if (invocation.metricKind) { if (invocation.metricKind) {
const lines = fs.readFileSync(invocation.args[0], "utf8").trim().split(/\r?\n/); const sourceLines = fs.readFileSync(invocation.args[0], "utf8").trim().split(/\r?\n/);
invocation.inputLines = sourceLines;
const lines = invocation.metricKind === "websiteClassifier"
? sourceLines.filter(line => !line.includes("UnknownScanner/1.0"))
: sourceLines;
const paths = {}; const paths = {};
for (const line of lines) { for (const line of lines) {
const requestPath = line.split("\t")[4]; const requestPath = line.split("\t")[4];
paths[requestPath] = (paths[requestPath] || 0) + 1; paths[requestPath] = (paths[requestPath] || 0) + 1;
} }
const report = { const report = {
general: { total_requests: lines.length }, general: { total_requests: lines.length, valid_requests: lines.length },
requests: { requests: {
data: Object.entries(paths).map(([requestPath, count]) => ({ data: Object.entries(paths).map(([requestPath, count]) => ({
data: requestPath, data: requestPath,
@@ -181,6 +185,42 @@ function fakeGoAccess(reports, calls, failureId) {
}; };
} }
test("uses GoAccess client classification before aggregating website paths and countries", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
const calls = [];
const reports = {
alpha: goAccessReport({ "2026-09-11": 2 }),
combined: goAccessReport({ "2026-09-11": 2 }, true)
};
fs.appendFileSync(testFixture.registry.sites[0].analyticsLog, analyticsLine({
requestPath: "/scanner/",
userAgent: "UnknownScanner/1.0"
}));
try {
generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess"
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess(reports, calls),
now: () => new Date("2026-09-11T12:00:00Z"),
skipLock: true
});
const state = JSON.parse(fs.readFileSync(testFixture.registry.privateMetrics.statePath, "utf8"));
assert.equal(state.sites.alpha.website.daily["2026-09-11"].pageViews, 2);
assert.equal(state.sites.alpha.website.daily["2026-09-11"].paths["/scanner/"], undefined);
const classifierCall = calls.find(call => call.metricKind === "websiteClassifier");
const aggregationCall = calls.find(call => call.metricKind === "website");
assert.equal(classifierCall.inputLines.some(line => line.includes("UnknownScanner/1.0")), true);
assert.equal(classifierCall.inputLines.every(line => line.split("\t")[4].startsWith("/__client/")), true);
assert.equal(aggregationCall.inputLines.some(line => line.includes("UnknownScanner/1.0")), false);
} finally {
testFixture.cleanup();
}
});
function run(testFixture, reports, calls = [], failureId) { function run(testFixture, reports, calls = [], failureId) {
return generateReports({ return generateReports({
registryPath: testFixture.registryPath, registryPath: testFixture.registryPath,
+26 -3
View File
@@ -57,6 +57,12 @@ test("parses analytics and regular Nginx combined records without query strings"
assert.equal(analytics.hour, "23"); assert.equal(analytics.hour, "23");
assert.equal(analytics.path, "/privacy/"); assert.equal(analytics.path, "/privacy/");
const emptyPath = parseAnalyticsLine(
'kst4contest.hamradioonline.de\t192.0.2.1\t2026-09-14T23:30:00+02:00\tGET\t\tHTTP/1.1\t400\t166\t"-"'
);
assert.equal(emptyPath.path, null);
assert.equal(isEligibleWebsiteRequest(emptyPath), false);
const combined = parseCombinedLine( const combined = parseCombinedLine(
'192.0.2.2 - - [14/Sep/2026:23:31:00 +0200] "GET /news/?x=1 HTTP/1.1" 200 43 "-" "Mozilla/5.0 Firefox/130"', '192.0.2.2 - - [14/Sep/2026:23:31:00 +0200] "GET /news/?x=1 HTTP/1.1" 200 43 "-" "Mozilla/5.0 Firefox/130"',
"kst4contest.hamradioonline.de" "kst4contest.hamradioonline.de"
@@ -84,7 +90,7 @@ test("uses Europe/Berlin across both daylight-saving transitions", () => {
test("keeps absolute countries including Switzerland, United Kingdom and unknown", () => { test("keeps absolute countries including Switzerland, United Kingdom and unknown", () => {
const website = aggregateGoAccessReport({ const website = aggregateGoAccessReport({
general: { total_requests: 5 }, general: { total_requests: 2245, valid_requests: 5 },
geolocation: { data: [ geolocation: { data: [
{ data: "Europe", hits: { count: 3 }, items: [ { data: "Europe", hits: { count: 3 }, items: [
{ data: "Germany", hits: { count: 1 } }, { data: "Germany", hits: { count: 1 } },
@@ -107,10 +113,27 @@ test("keeps absolute countries including Switzerland, United Kingdom and unknown
}); });
assert.deepEqual(website.paths, { "/": 2, "/privacy/": 2, "/news/": 1 }); assert.deepEqual(website.paths, { "/": 2, "/privacy/": 2, "/news/": 1 });
assert.throws(() => aggregateGoAccessReport({ assert.throws(() => aggregateGoAccessReport({
general: { total_requests: 2 }, general: { total_requests: 10, valid_requests: 2 },
geolocation: { data: [{ data: "Germany", hits: { count: 2 } }] }, geolocation: { data: [{ data: "Germany", hits: { count: 2 } }] },
requests: { data: [{ data: "/", hits: { count: 1 } }] } requests: { data: [{ data: "/", hits: { count: 1 } }] }
}, "website"), /differs from the GoAccess request-panel definition/); }, "website"), /valid-request count differs from the request-panel definition/);
const updates = aggregateGoAccessReport({
general: { total_requests: 9, valid_requests: 2 },
geolocation: { data: [{ data: "Germany", hits: { count: 2 } }] },
requests: { data: [{ data: "/kst4ContestVersionInfo.xml", hits: { count: 2 } }] }
}, "updateInfo");
assert.equal(updates.requests, 2);
assert.throws(() => aggregateGoAccessReport({
general: { total_requests: 2, valid_requests: 1 },
geolocation: { data: [{ data: "Germany", hits: { count: 2 } }] },
requests: { data: [{ data: "/", hits: { count: 1 } }] }
}, "website"), /country total exceeds/);
assert.throws(() => aggregateGoAccessReport({
general: { total_requests: 1, valid_requests: 1 },
geolocation: { data: [{ data: "Germany", hits: { count: 1 } }] },
requests: { data: [{ data: "/unexpected", hits: { count: 1 } }] }
}, "updateInfo"), /unexpected request path/);
}); });
test("deduplicates overlapping import files and reads gzip without GoAccess Zlib", () => { test("deduplicates overlapping import files and reads gzip without GoAccess Zlib", () => {