mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-16 06:05:29 +02:00
Changes at the stats analytics
This commit is contained in:
+257
-63
@@ -5,10 +5,12 @@ runbook for the production analytics service on Ubuntu Server 24.04. Nothing in
|
||||
the repository installs, updates or activates the server-side components
|
||||
automatically.
|
||||
|
||||
The design has two separate outputs:
|
||||
The design has three separate outputs:
|
||||
|
||||
- private static GoAccess HTML and JSON reports for each registered project
|
||||
subdomain and for all registered project subdomains combined;
|
||||
- private durable daily and annual views of website page views and successful
|
||||
requests for the update-information XML file;
|
||||
- a small public `visitor-count.json` file for sites which explicitly enable
|
||||
the counter.
|
||||
|
||||
@@ -37,6 +39,17 @@ durable daily counter state --> public visitor-count.json
|
||||
same-origin home-page request
|
||||
|
||||
private HTML reports --> Nginx Basic Auth --> stats.hamradioonline.de
|
||||
|
||||
exact GET + HTTP 200 for /kst4ContestVersionInfo.xml
|
||||
|
|
||||
v
|
||||
separate Nginx update-information log (14 days)
|
||||
|
|
||||
v
|
||||
durable daily totals and countries
|
||||
+--> hourly and client-group detail (14 days)
|
||||
|
||||
eligible page log --> durable daily page views, countries and paths
|
||||
```
|
||||
|
||||
Nginx writes a dedicated, reduced log. For each site, the generator gives the
|
||||
@@ -54,6 +67,13 @@ being added again. This makes repeated runs idempotent. Values older than 395
|
||||
days remain in the counter state and continue to contribute to the public
|
||||
total.
|
||||
|
||||
The private metric state is separate again. It retains website page-view
|
||||
totals, absolute country values and individual normalized paths per day, plus
|
||||
the equivalent totals and countries for update-information requests. Hourly
|
||||
and recognisable client-group detail for update requests is removed after 14
|
||||
days. The generator derives annual totals from the durable daily values. It
|
||||
does not build a permanent path-by-country table.
|
||||
|
||||
## Production platform
|
||||
|
||||
The confirmed production baseline is:
|
||||
@@ -64,7 +84,7 @@ The confirmed production baseline is:
|
||||
- GoAccess 1.8.1 with GeoIP2/MMDB and OpenSSL support, but without Zlib;
|
||||
- a local GeoLite2-Country database;
|
||||
- systemd for the oneshot generator and its hourly timer;
|
||||
- Logrotate for the dedicated analytics log.
|
||||
- Logrotate for the dedicated website and update-information logs.
|
||||
|
||||
Missing Zlib support is intentional for this operating model. The generator
|
||||
processes the current uncompressed analytics log and the optional uncompressed
|
||||
@@ -74,18 +94,21 @@ processes the current uncompressed analytics log and the optional uncompressed
|
||||
|
||||
- `generate-reports.js` validates configuration and state, runs GoAccess and
|
||||
publishes outputs atomically.
|
||||
- `private-metrics.js` parses the reduced logs, maintains durable daily
|
||||
aggregates and renders the protected daily and annual views.
|
||||
- `sites.example.json` is the registry template.
|
||||
- `goaccess.conf.template` is rendered per report with a private database path
|
||||
and the configured GeoIP2 Country database.
|
||||
- `nginx/` contains the reduced log format, request filters, public endpoint
|
||||
and protected report-vhost examples.
|
||||
- `systemd/` contains a hardened oneshot service and hourly timer.
|
||||
- `logrotate/` retains 14 daily analytics-log rotations.
|
||||
- `logrotate/` retains 14 daily rotations for both reduced logs.
|
||||
|
||||
These repository files are templates and source files. Their productive
|
||||
counterparts are installed separately:
|
||||
|
||||
- generator: `/opt/hamradioonline-analytics/generate-reports.js`;
|
||||
- private metric module: `/opt/hamradioonline-analytics/private-metrics.js`;
|
||||
- registry: `/etc/hamradioonline-analytics/sites.json`;
|
||||
- GoAccess template:
|
||||
`/etc/hamradioonline-analytics/goaccess.conf.template`;
|
||||
@@ -104,7 +127,8 @@ counterparts are installed separately:
|
||||
examples.
|
||||
|
||||
No npm package is required by the generator. GoAccess is the only external
|
||||
program it starts.
|
||||
program it starts. Historical `.gz` input is decompressed by Node.js and does
|
||||
not require Zlib support in the installed GoAccess 1.8.1 binary.
|
||||
|
||||
The production compatibility baseline is GoAccess 1.8.1 built with
|
||||
`--enable-geoip=mmdb` and `--with-openssl`, but without `--with-zlib`. Zlib is
|
||||
@@ -128,10 +152,10 @@ home directory, `/usr/sbin/nologin` as its shell and a locked password. It runs
|
||||
the generator and GoAccess, reads the reduced logs and configuration, and
|
||||
writes only below the configured service-state directories.
|
||||
|
||||
Nginx runs as `www-data`. It writes the dedicated analytics log and reads the
|
||||
private reports and public counter. It must not be able to read the private
|
||||
GoAccess databases or `public-counter-state.json`, and it has no write access
|
||||
to generated output.
|
||||
Nginx runs as `www-data`. It writes the dedicated website and update-information
|
||||
logs and reads the private reports and public counter. It must not be able to
|
||||
read the private GoAccess databases, `public-counter-state.json` or
|
||||
`private-metrics-state.json`, and it has no write access to generated output.
|
||||
|
||||
`stats-reader` is the current local Nginx Basic Auth username. It is not a
|
||||
Linux service account and not an account with an external analytics provider.
|
||||
@@ -161,6 +185,9 @@ sudo install -d -o root -g hamradio-analytics -m 0750 \
|
||||
sudo install -o root -g hamradio-analytics -m 0750 \
|
||||
website/ops/analytics/generate-reports.js \
|
||||
/opt/hamradioonline-analytics/generate-reports.js
|
||||
sudo install -o root -g hamradio-analytics -m 0640 \
|
||||
website/ops/analytics/private-metrics.js \
|
||||
/opt/hamradioonline-analytics/private-metrics.js
|
||||
sudo install -o root -g hamradio-analytics -m 0640 \
|
||||
website/ops/analytics/goaccess.conf.template \
|
||||
/etc/hamradioonline-analytics/goaccess.conf.template
|
||||
@@ -191,6 +218,7 @@ sudo install -d -o hamradio-analytics -g www-data -m 2750 \
|
||||
/var/lib/hamradioonline-analytics/reports \
|
||||
/var/lib/hamradioonline-analytics/reports/combined \
|
||||
/var/lib/hamradioonline-analytics/reports/kst4contest \
|
||||
/var/lib/hamradioonline-analytics/reports/metrics \
|
||||
/var/lib/hamradioonline-analytics/public \
|
||||
/var/lib/hamradioonline-analytics/public/kst4contest
|
||||
```
|
||||
@@ -204,13 +232,16 @@ boundary after systemd has prepared the state directory.
|
||||
The productive ownership and mode boundaries are:
|
||||
|
||||
- generator: `0750 root:hamradio-analytics`;
|
||||
- private metric module: `0640 root:hamradio-analytics`;
|
||||
- registry and GoAccess configuration: `0640 root:hamradio-analytics`;
|
||||
- report directories, including `reports/combined` and
|
||||
`reports/kst4contest`: `2750 hamradio-analytics:www-data`;
|
||||
- report directories, including `reports/combined`, `reports/kst4contest` and
|
||||
`reports/metrics`: `2750 hamradio-analytics:www-data`;
|
||||
- report files: `0640 hamradio-analytics:www-data`;
|
||||
- private database files: `0640 hamradio-analytics:hamradio-analytics`;
|
||||
- `public-counter-state.json`: mode `0640`, owner and group
|
||||
`hamradio-analytics:hamradio-analytics`;
|
||||
- `private-metrics-state.json`: mode `0640`, owner and group
|
||||
`hamradio-analytics:hamradio-analytics`;
|
||||
- public output directories, including `public/kst4contest`: mode `2750`,
|
||||
owner and group `hamradio-analytics:www-data`;
|
||||
- public `visitor-count.json`: `0644 hamradio-analytics:www-data`;
|
||||
@@ -234,11 +265,17 @@ if [ ! -e /var/log/nginx/kst4contest-analytics.log ]; then
|
||||
sudo install -o www-data -g hamradio-analytics -m 0640 /dev/null \
|
||||
/var/log/nginx/kst4contest-analytics.log
|
||||
fi
|
||||
if [ ! -e /var/log/nginx/kst4contest-update-information.log ]; then
|
||||
sudo install -o www-data -g hamradio-analytics -m 0640 /dev/null \
|
||||
/var/log/nginx/kst4contest-update-information.log
|
||||
fi
|
||||
sudo stat -c '%U:%G %a %n' /var/log/nginx/kst4contest-analytics.log
|
||||
sudo stat -c '%U:%G %a %n' \
|
||||
/var/log/nginx/kst4contest-update-information.log
|
||||
```
|
||||
|
||||
The resulting log owner and mode must be
|
||||
`www-data:hamradio-analytics 640`. Logrotate preserves that ownership. The
|
||||
`www-data:hamradio-analytics 640` for both files. Logrotate preserves that ownership. The
|
||||
generator's configuration check fails clearly if required output directories
|
||||
are missing or if the executing user cannot read an analytics log or the
|
||||
Country database.
|
||||
@@ -252,33 +289,48 @@ server layout. Each site entry contains:
|
||||
- its exact `hostname`;
|
||||
- the current, uncompressed `analyticsLog` path;
|
||||
- the `activatedOn` date used by the public counter;
|
||||
- `websiteMetricsSince`, the first day covered by the new live website
|
||||
page-view aggregation;
|
||||
- an `updateInfo` object containing the exact XML path, its separate current
|
||||
log and the first day covered by live update-information aggregation;
|
||||
- a `publicCounter` switch;
|
||||
- the private `reportOutputDirectory`;
|
||||
- a `publicJsonPath` when the public counter is enabled.
|
||||
|
||||
The top-level `combined.reportOutputDirectory` receives the combined report.
|
||||
The top-level `privateMetrics` object defines its private state and report
|
||||
paths, fixes the time zone to `Europe/Berlin` and fixes detailed retention to
|
||||
14 days. The top-level `combined.reportOutputDirectory` receives the combined report.
|
||||
Only registered sites are included. The generator rejects
|
||||
`stats.hamradioonline.de`, so the report host cannot accidentally become part
|
||||
of the project statistics.
|
||||
|
||||
The dates in `sites.example.json` document the repository snapshot; they are
|
||||
not installation defaults. Before enabling the new logs, replace
|
||||
`websiteMetricsSince` and `updateInfo.metricsSince` with the actual first live
|
||||
coverage day. If collection started earlier than the current/`.1` handover,
|
||||
import the covered rotations before the first regular run. Do not backdate a
|
||||
field merely to obtain an earlier-looking report.
|
||||
|
||||
To add another project subdomain later, add one registry entry and one matching
|
||||
dedicated `access_log` line to its Nginx server block. Do not enable a public
|
||||
counter unless that site should publish one.
|
||||
|
||||
Treat `activatedOn` as persistent data. Once counting has started, changing it
|
||||
Treat `activatedOn`, `websiteMetricsSince` and `updateInfo.metricsSince` as
|
||||
persistent data. Once counting has started, changing one of these values
|
||||
would change the meaning of the total. The generator refuses to combine a new
|
||||
activation date with existing counter state.
|
||||
date with the corresponding existing state.
|
||||
|
||||
The hostname is persistent identity as well. If an existing site state has a
|
||||
different `activatedOn` or hostname, do not delete the state to make the next
|
||||
run pass. Changing either value requires a deliberate migration or a
|
||||
specifically approved reset of the public count.
|
||||
|
||||
The generator derives the optional `.1` path from `analyticsLog`. It is valid
|
||||
for `.1` not to exist before the first rotation. Do not enter a rotation or a
|
||||
compressed `.gz` file in the registry.
|
||||
The generator derives the optional `.1` paths from `analyticsLog` and
|
||||
`updateInfo.analyticsLog`. It is valid for `.1` not to exist before the first
|
||||
rotation. Do not enter a rotation or a compressed `.gz` file in the registry.
|
||||
|
||||
Adding another project subdomain also requires its own Nginx analytics log,
|
||||
Adding another project subdomain also requires its own Nginx website and
|
||||
update-information logs,
|
||||
the corresponding Logrotate ownership, a prepared report directory and, when
|
||||
enabled, a public-output directory and counter location. The combined report
|
||||
uses the logs of every registered project site. The statistics vhost remains
|
||||
@@ -295,9 +347,9 @@ The relevant production configuration files are:
|
||||
- `/etc/nginx/sites-available/stats.hamradioonline.de`.
|
||||
|
||||
Install the log-format and filter maps from `nginx/` in the `http` context.
|
||||
Then add a dedicated analytics `access_log` to every registered project server
|
||||
block. Keep the existing operational access log unless its replacement has
|
||||
been reviewed separately. If the operational log is inherited from the
|
||||
Then add the dedicated website and update-information `access_log` directives
|
||||
to every registered project server block. Keep the existing operational access
|
||||
log unless its replacement has been reviewed separately. If the operational log is inherited from the
|
||||
`http` context, repeat its directive in the server block before adding the
|
||||
analytics log; an `access_log` at a lower level changes inheritance.
|
||||
|
||||
@@ -333,9 +385,10 @@ The analytics format contains only:
|
||||
- transferred body size;
|
||||
- user agent.
|
||||
|
||||
The fields are tab-separated. The production log is
|
||||
`/var/log/nginx/kst4contest-analytics.log`. Nginx writes it as `www-data`; the
|
||||
`hamradio-analytics` group can read it. The confirmed owner and mode are
|
||||
The fields are tab-separated. The production website log is
|
||||
`/var/log/nginx/kst4contest-analytics.log`; the separate XML log is
|
||||
`/var/log/nginx/kst4contest-update-information.log`. Nginx writes both as
|
||||
`www-data`; the `hamradio-analytics` group can read them. The confirmed owner and mode are
|
||||
`www-data:hamradio-analytics 0640`. The analytics service receives read-only
|
||||
access and must never truncate or otherwise modify this log.
|
||||
|
||||
@@ -344,7 +397,7 @@ analytics log. It also omits referrer and authenticated remote-user data. The
|
||||
user agent is retained because GoAccess needs it for crawler classification
|
||||
and its visit definition.
|
||||
|
||||
Only eligible `GET` requests can be logged. Assets, downloads, status and
|
||||
Only eligible `GET` requests enter the website analytics log. Assets, downloads, status and
|
||||
monitoring paths, sitemap, robots file, favicons, the update feed and the
|
||||
public counter endpoint are excluded. Known bots, crawlers, monitoring
|
||||
clients, `wget` and `curl` are rejected before logging. GoAccess applies its
|
||||
@@ -352,6 +405,14 @@ own crawler list as a second layer and treats unknown browser or operating
|
||||
system combinations as crawlers. The public counter request therefore cannot
|
||||
count itself, and the statistics vhost has no analytics logging of its own.
|
||||
|
||||
The update-information map is deliberately independent of the website and bot
|
||||
filters. It logs only an exact case-sensitive `GET` request for
|
||||
`/kst4ContestVersionInfo.xml` when the final response status is `200`. `HEAD`,
|
||||
`304`, redirects and error responses do not enter this log. Browsers and bots
|
||||
are not excluded, because the metric describes successful file requests, not
|
||||
program starts or people. The XML remains excluded from the website log, so it
|
||||
does not change website page views, visits or `visitor-count.json`.
|
||||
|
||||
Review the monitoring-path list against the real server before activation.
|
||||
When a new health endpoint or asset family is added, update the filter first.
|
||||
|
||||
@@ -363,8 +424,8 @@ sudo nginx -t
|
||||
|
||||
### Log rotation
|
||||
|
||||
`/etc/logrotate.d/hamradioonline-analytics` rotates the dedicated analytics
|
||||
logs daily, retains 14 rotations and compresses older files. `delaycompress`
|
||||
`/etc/logrotate.d/hamradioonline-analytics` rotates both dedicated logs daily,
|
||||
retains 14 rotations and compresses older files. `delaycompress`
|
||||
is an operational requirement: it keeps the immediately preceding rotation
|
||||
as an uncompressed `.1` file for the next generator run. The `create 0640
|
||||
www-data hamradio-analytics` directive preserves the write/read boundary.
|
||||
@@ -372,8 +433,8 @@ After rotation, `invoke-rc.d nginx rotate` makes Nginx reopen its logs.
|
||||
|
||||
The generator processes, in this order:
|
||||
|
||||
1. the optional, uncompressed `.1` rotation;
|
||||
2. the current analytics log.
|
||||
1. each optional, uncompressed `.1` rotation;
|
||||
2. each corresponding current log.
|
||||
|
||||
A missing `.1` is normal, including before the first rotation. Older `.gz`
|
||||
files are retained according to Logrotate but are not imported by the regular
|
||||
@@ -419,20 +480,45 @@ succeeded.
|
||||
|
||||
Logrotate must use `delaycompress`, as shown in the example. This leaves `.1`
|
||||
uncompressed for one rotation cycle. Older `.gz` files are not part of the
|
||||
regular hourly run, and importing them is a separate maintenance task outside
|
||||
this repository workflow. Do not add an unstable decompression pipeline to
|
||||
the timer service.
|
||||
regular hourly run. The explicit historical-import mode can read them through
|
||||
Node.js; it never asks the GoAccess binary to decompress them. Do not add an
|
||||
incremental decompression pipeline to the timer service.
|
||||
|
||||
If the generator is unavailable for longer than the uncompressed rotation
|
||||
window, the regular run cannot recover entries found only in older `.gz`
|
||||
files. Preserve those files under the raw-log retention policy and plan any
|
||||
necessary historical import separately before resuming normal processing.
|
||||
|
||||
### Durable private aggregates
|
||||
|
||||
For each covered day, the generator rebuilds the relevant slice from the
|
||||
available reduced logs and replaces or monotonically extends the stored value.
|
||||
It does not add a whole hourly result to the previous result. Website page
|
||||
views use the same Nginx page/bot filters and the same GoAccess crawler
|
||||
classification as the existing reports. The generator verifies that the sum
|
||||
of all path values equals GoAccess's request total; a discrepancy stops the run
|
||||
instead of silently establishing a second page-view definition.
|
||||
|
||||
The website series stores page-view totals, absolute Country-panel values and
|
||||
every normalized path per day. The update-information series stores successful
|
||||
request totals and absolute Country-panel values per day. A missing Country
|
||||
assignment is stored as `Unknown`; no city or exact location is inferred.
|
||||
Hourly update-request totals and conservative client groups are kept only for
|
||||
the latest 14 calendar days in `Europe/Berlin`. Raw user agents never enter the
|
||||
durable metric state. A Java user agent is labelled as an unknown Java
|
||||
application, not as KST4Contest.
|
||||
|
||||
Annual totals, annual Country totals and annual page totals are calculated from
|
||||
the daily state when the static reports are generated. The first covered day
|
||||
is shown for every series. Earlier dates are unknown and are not emitted as
|
||||
zero. There is deliberately no permanent path-by-Country-by-request table.
|
||||
|
||||
### Visit and privacy boundary
|
||||
|
||||
GoAccess treats requests with the same IP address, date and user agent as one
|
||||
visit. The public number is therefore an approximate visit total, not a count
|
||||
of uniquely identified people. Page views remain a separate statistic.
|
||||
of uniquely identified people. Page views remain a separate statistic and are
|
||||
displayed separately from the durable daily visit values.
|
||||
|
||||
IP addresses are processed with the configured GoAccess anonymisation level.
|
||||
Country resolution happens locally against GeoLite2-Country; City and host
|
||||
@@ -447,14 +533,15 @@ no visitor-level or daily detail.
|
||||
|
||||
## Persistence and publication
|
||||
|
||||
The installation has four distinct persistence layers.
|
||||
The installation has five distinct persistence layers.
|
||||
|
||||
### Raw logs
|
||||
|
||||
The current analytics log and its rotations are short-lived input. The current
|
||||
file and `.1` bridge requests across the most recent rotation. Logrotate limits
|
||||
raw-log retention to the published 14-day policy; backups must not silently
|
||||
extend that period.
|
||||
The current website and update-information logs and their rotations are
|
||||
short-lived input. Each current file and `.1` bridge requests across the most
|
||||
recent rotation. These files contain individual IP addresses, timestamps and
|
||||
raw user agents. Logrotate limits their retention to the published 14-day
|
||||
policy; backups must not silently extend that period.
|
||||
|
||||
### GoAccess databases
|
||||
|
||||
@@ -480,6 +567,22 @@ The stored hostname and `activatedOn` must continue to match the registry.
|
||||
Changing either value requires a planned migration or an approved reset, not
|
||||
an ad-hoc edit or deletion of the state file.
|
||||
|
||||
### Private metric state
|
||||
|
||||
`/var/lib/hamradioonline-analytics/private-metrics-state.json` is the durable
|
||||
source for daily website page views and update-information requests. It stores
|
||||
daily totals and absolute Country values; website days also store normalized
|
||||
path totals. Only update-information days within the latest 14-day window may
|
||||
contain hourly and client-group aggregates. The file contains no individual
|
||||
IP address, raw user agent or individual timestamp and remains unreadable by
|
||||
Nginx.
|
||||
|
||||
Daily values are upserted, not added. Re-reading the current log, `.1` or an
|
||||
overlapping historical import therefore does not multiply a day. A conflicting
|
||||
or decreasing overlap stops the run for investigation. Keep this file with
|
||||
the public counter state and GoAccess databases in the later separate
|
||||
backup/recovery plan.
|
||||
|
||||
### Reports and public files
|
||||
|
||||
The derived outputs are:
|
||||
@@ -488,6 +591,8 @@ The derived outputs are:
|
||||
- `/var/lib/hamradioonline-analytics/reports/kst4contest/report.json`;
|
||||
- `/var/lib/hamradioonline-analytics/reports/combined/report.html`;
|
||||
- `/var/lib/hamradioonline-analytics/reports/combined/report.json`;
|
||||
- `/var/lib/hamradioonline-analytics/reports/metrics/report.html` and its
|
||||
per-site daily/yearly pages;
|
||||
- `/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json`.
|
||||
|
||||
The generator prepares every GoAccess job in a run directory, validates the
|
||||
@@ -500,7 +605,7 @@ transaction across every report, database and counter file; after a storage or
|
||||
permission failure during publication, inspect the complete set and rerun the
|
||||
service after correcting the cause.
|
||||
|
||||
Counter state and GoAccess databases are the important persistent sources.
|
||||
Counter state, private metric state and GoAccess databases are the important persistent sources.
|
||||
HTML/JSON reports and `visitor-count.json` are derived and can be rebuilt when
|
||||
their corresponding source state is available.
|
||||
|
||||
@@ -512,10 +617,14 @@ the reviewed repository file:
|
||||
|
||||
```sh
|
||||
sudo stat -c '%U:%G %a %s %n' \
|
||||
/opt/hamradioonline-analytics/generate-reports.js
|
||||
/opt/hamradioonline-analytics/generate-reports.js \
|
||||
/opt/hamradioonline-analytics/private-metrics.js
|
||||
sha256sum /srv/git/kst4contest/website/ops/analytics/generate-reports.js \
|
||||
/opt/hamradioonline-analytics/generate-reports.js
|
||||
/opt/hamradioonline-analytics/generate-reports.js \
|
||||
/srv/git/kst4contest/website/ops/analytics/private-metrics.js \
|
||||
/opt/hamradioonline-analytics/private-metrics.js
|
||||
/usr/bin/node --check /opt/hamradioonline-analytics/generate-reports.js
|
||||
/usr/bin/node --check /opt/hamradioonline-analytics/private-metrics.js
|
||||
```
|
||||
|
||||
A zero-byte JavaScript file is syntactically valid and exits successfully
|
||||
@@ -563,6 +672,56 @@ history which is no longer present in the raw logs. Back up this state file: it
|
||||
is the durable source for public totals older than the detailed retention
|
||||
window.
|
||||
|
||||
### Historical import
|
||||
|
||||
Historical import is a manual maintenance operation. Do not add import options
|
||||
to the systemd unit. First make a protected working copy of the still available
|
||||
regular Nginx access logs, including `.1` and `.gz`, and inventory their actual
|
||||
first and last records. Use only a continuous date range which is genuinely
|
||||
covered by the selected files. A missing earlier file is missing history, not a
|
||||
zero day.
|
||||
|
||||
The importer accepts either the standard Nginx combined format or the reduced
|
||||
tab-separated analytics format. It rejects malformed lines, unreadable files,
|
||||
unknown sites and invalid coverage ranges. Do not guess a production log
|
||||
format: compare a redacted sample with the selected parser before the import.
|
||||
For a standard combined-log import:
|
||||
|
||||
```sh
|
||||
sudo -u hamradio-analytics /usr/bin/node \
|
||||
/opt/hamradioonline-analytics/generate-reports.js \
|
||||
--registry /etc/hamradioonline-analytics/sites.json \
|
||||
--config-template /etc/hamradioonline-analytics/goaccess.conf.template \
|
||||
--import-site kst4contest \
|
||||
--import-format nginx-combined \
|
||||
--coverage-from YYYY-MM-DD \
|
||||
--coverage-through YYYY-MM-DD \
|
||||
--import-log /protected/import/access.log.3.gz \
|
||||
--import-log /protected/import/access.log.2.gz \
|
||||
--import-log /protected/import/access.log.1
|
||||
```
|
||||
|
||||
Use `analytics-tsv` only when the source is genuinely in the maintained
|
||||
reduced format. Query strings are removed and paths normalized by the importer.
|
||||
Website requests pass the same maintained page and known-bot filters and then
|
||||
GoAccess's crawler classification. Update-information requests require exact
|
||||
`GET`/`200` semantics and do not exclude bots.
|
||||
|
||||
The selected input set is aggregated by complete day. Duplicate copies of the
|
||||
same records across selected files are collapsed while repeated identical
|
||||
records within one source remain counted. Each imported day replaces or
|
||||
monotonically extends its stored aggregate; rerunning the same command is
|
||||
idempotent. An overlap which changes distributions without a consistent higher
|
||||
total fails instead of adding uncertain data. Run once against a protected copy
|
||||
of the private state or with `--dry-run`, inspect the first covered dates and
|
||||
totals, then run productively. Keep a pre-import backup until a second identical
|
||||
run confirms stable totals.
|
||||
|
||||
Node.js decompresses `.gz` inputs itself. The production GoAccess 1.8.1 binary
|
||||
does not need Zlib support. Remove the protected import copies according to the
|
||||
14-day raw-data limit after the verified import; do not put them in a durable
|
||||
backup.
|
||||
|
||||
### Safe verification sequence
|
||||
|
||||
Use this order for a new installation, a recovered service or a material
|
||||
@@ -585,8 +744,9 @@ generator/configuration update:
|
||||
run ends with `Analytics generation completed`.
|
||||
9. Check every generated file's path, owner, group, mode and timestamp.
|
||||
10. Request the public JSON through HTTPS and validate its four fields.
|
||||
11. Request both private report URLs with Basic Auth. Let the client prompt for
|
||||
the password; never put it directly on a command line.
|
||||
11. Request the existing GoAccess URLs and `/metrics/` with Basic Auth. Follow
|
||||
its website and update-information daily/yearly links. Let the client prompt
|
||||
for the password; never put it directly on a command line.
|
||||
12. Run the service a second time and confirm that the total and report values
|
||||
develop plausibly rather than multiplying the existing history.
|
||||
13. Enable or re-enable the timer only after these checks pass.
|
||||
@@ -655,6 +815,11 @@ The current private endpoints are:
|
||||
- `https://stats.hamradioonline.de/combined/`, which serves the combined
|
||||
report;
|
||||
- `https://stats.hamradioonline.de/kst4contest/`, which serves the site report.
|
||||
- `https://stats.hamradioonline.de/metrics/`, which links to the separate
|
||||
website and update-information daily/yearly views.
|
||||
|
||||
The generator adds a small `/metrics/` link to each generated GoAccess HTML
|
||||
report. The authenticated `/` redirect to `/combined/` remains unchanged.
|
||||
|
||||
All HTTPS paths, including the redirect target, remain behind Basic Auth.
|
||||
Reports use `Cache-Control: private, no-store`,
|
||||
@@ -767,12 +932,13 @@ analytics logging is active.
|
||||
|
||||
## Regular operation
|
||||
|
||||
Nginx continuously writes only eligible requests to the dedicated analytics
|
||||
log. The systemd timer starts the generator once per hour. Every successful
|
||||
run refreshes the per-site and combined reports, persists the corresponding
|
||||
GoAccess databases, updates daily counter values and finally publishes enabled
|
||||
public counters. Logrotate handles the raw log once per day and preserves the
|
||||
uncompressed `.1` handover file required by the generator.
|
||||
Nginx continuously writes eligible website requests and exact successful
|
||||
update-information requests to separate reduced logs. The systemd timer starts
|
||||
the generator once per hour. Every successful run refreshes the per-site and
|
||||
combined GoAccess reports, private daily/yearly views, daily visit counter
|
||||
values and enabled public counters. Logrotate handles both raw logs once per
|
||||
day and preserves each uncompressed `.1` handover file required by the
|
||||
generator.
|
||||
|
||||
The normal operator signal is the service result and journal, not a permanently
|
||||
running process: the generator is a short-lived oneshot service. There is no
|
||||
@@ -880,8 +1046,9 @@ present. The current analytics log remains required.
|
||||
### `.gz` rotations on a GoAccess build without Zlib
|
||||
|
||||
This is normal. Regular operation does not read `.gz` files. Do not configure
|
||||
a compressed or rotated file as `analyticsLog`; any exceptional historical
|
||||
import must be planned separately.
|
||||
a compressed or rotated file as `analyticsLog` or `updateInfo.analyticsLog`.
|
||||
Use `.gz` only through the explicit historical-import options; Node.js, not
|
||||
GoAccess, decompresses that input.
|
||||
|
||||
### MMDB missing or unreadable
|
||||
|
||||
@@ -900,11 +1067,32 @@ replace or repurpose the normal operational access log.
|
||||
sudo nginx -t
|
||||
sudo nginx -T
|
||||
sudo stat -c '%U:%G %a %s %y %n' \
|
||||
/var/log/nginx/kst4contest-analytics.log
|
||||
/var/log/nginx/kst4contest-analytics.log \
|
||||
/var/log/nginx/kst4contest-update-information.log
|
||||
sudo -u hamradio-analytics test -r \
|
||||
/var/log/nginx/kst4contest-analytics.log
|
||||
sudo -u hamradio-analytics test -r \
|
||||
/var/log/nginx/kst4contest-update-information.log
|
||||
```
|
||||
|
||||
### Private metrics gap
|
||||
|
||||
If the journal says that a private metric gap starts before the regular
|
||||
current/`.1` handover window, stop the timer. Do not turn the missing dates
|
||||
into zeros and do not delete `private-metrics-state.json`. Inventory the
|
||||
remaining regular and reduced logs, make a protected state backup and use the
|
||||
documented historical import only for a genuinely covered range. If no
|
||||
reliable source remains, the first covered date must move forward through a
|
||||
reviewed state migration; that is not an automatic repair.
|
||||
|
||||
### Historical import rejects a log
|
||||
|
||||
An invalid line normally means that the selected `nginx-combined` or
|
||||
`analytics-tsv` parser does not match the real file, or that a file is damaged.
|
||||
Inspect a redacted sample and the file boundaries. Do not delete failing lines
|
||||
or switch formats until the actual Nginx log format is confirmed. A failed
|
||||
import leaves the existing private state and reports unchanged.
|
||||
|
||||
### Public counter returns `404`
|
||||
|
||||
This is expected before the first successful generation. Afterwards inspect
|
||||
@@ -983,6 +1171,7 @@ part of this repository change.
|
||||
At minimum, the later plan must cover:
|
||||
|
||||
- `/var/lib/hamradioonline-analytics/public-counter-state.json`;
|
||||
- `/var/lib/hamradioonline-analytics/private-metrics-state.json`;
|
||||
- `/var/lib/hamradioonline-analytics/db`;
|
||||
- `/etc/hamradioonline-analytics`;
|
||||
- the installed systemd units;
|
||||
@@ -997,20 +1186,23 @@ Handle the Basic Auth hash, MaxMind Account ID and License Key, GitHub deploy
|
||||
token, ACME account data and private TLS keys as secrets. Never copy them into
|
||||
Git, public documentation, logs or ordinary support bundles.
|
||||
|
||||
The generator is recoverable from GitHub, and GeoLite2-Country can be fetched
|
||||
again with `geoipupdate`. HTML/JSON reports can be rebuilt when the GoAccess
|
||||
databases or sufficient raw logs remain. The public JSON can be rebuilt from
|
||||
the counter state.
|
||||
The generator modules are recoverable from GitHub, and GeoLite2-Country can be
|
||||
fetched again with `geoipupdate`. HTML/JSON reports can be rebuilt when their
|
||||
GoAccess databases or durable state remain. The public JSON can be rebuilt
|
||||
from the counter state; the private daily/yearly pages can be rebuilt from
|
||||
`private-metrics-state.json`.
|
||||
|
||||
The accumulated public total is not fully recoverable without
|
||||
`public-counter-state.json`. Older detailed aggregates are not recoverable
|
||||
without the GoAccess databases, and historical raw requests disappear after
|
||||
the 14-day rotation window.
|
||||
|
||||
Do not let backups extend the published raw-log retention by accident. Either
|
||||
exclude analytics raw logs from durable backups or enforce the same confirmed
|
||||
retention limit in backup storage. Counter state and anonymised/aggregated
|
||||
GoAccess state can be governed separately.
|
||||
The durable website page-view and update-information history is not fully
|
||||
recoverable without `private-metrics-state.json`. This state is aggregated and
|
||||
belongs in the protected backup plan. Do not let backups extend the published
|
||||
raw-log retention by accident: exclude raw logs or enforce the same 14-day
|
||||
maximum in backup storage. Counter state and anonymised or aggregated GoAccess
|
||||
state can be governed separately.
|
||||
|
||||
### Recovery order
|
||||
|
||||
@@ -1020,7 +1212,8 @@ GoAccess state can be governed separately.
|
||||
4. Install the generator and non-secret configuration.
|
||||
5. Restore secrets and certificate state from protected backup storage.
|
||||
6. Restore GeoLite2-Country or download it again.
|
||||
7. Restore the GoAccess databases and public counter state.
|
||||
7. Restore the GoAccess databases, public counter state and private metric
|
||||
state.
|
||||
8. Validate Nginx, systemd and Logrotate configuration.
|
||||
9. Run the generator with `--check`.
|
||||
10. Run `--dry-run` and verify that production state remains unchanged.
|
||||
@@ -1030,8 +1223,9 @@ GoAccess state can be governed separately.
|
||||
|
||||
## Outstanding operational checks
|
||||
|
||||
The first real rotation of the dedicated analytics log still needs explicit
|
||||
observation. This is not a current service blocker. After rotation, confirm:
|
||||
The first real rotation after installing the separate update-information log
|
||||
still needs explicit observation. This is not a current service blocker. For
|
||||
both reduced logs, confirm:
|
||||
|
||||
- a new current log exists;
|
||||
- `.1` exists and remains uncompressed;
|
||||
|
||||
@@ -5,6 +5,11 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const {
|
||||
UPDATE_INFO_PATH,
|
||||
generatePrivateMetrics,
|
||||
validateImportOptions
|
||||
} = require("./private-metrics");
|
||||
|
||||
const STATE_SCHEMA_VERSION = 1;
|
||||
const PUBLIC_SCHEMA_VERSION = 1;
|
||||
@@ -156,6 +161,34 @@ function validateRegistry(registry) {
|
||||
}
|
||||
analyticsLogPaths.add(analyticsLog);
|
||||
|
||||
const websiteMetricsSince = parseIsoDate(site.websiteMetricsSince);
|
||||
if (!websiteMetricsSince) {
|
||||
throw new ConfigurationError(`${label}.websiteMetricsSince must be a valid ISO date`);
|
||||
}
|
||||
if (!site.updateInfo || typeof site.updateInfo !== "object" || Array.isArray(site.updateInfo)) {
|
||||
throw new ConfigurationError(`${label}.updateInfo must be an object`);
|
||||
}
|
||||
if (site.updateInfo.path !== UPDATE_INFO_PATH) {
|
||||
throw new ConfigurationError(`${label}.updateInfo.path must be ${UPDATE_INFO_PATH}`);
|
||||
}
|
||||
const updateInfoLog = requireAbsolutePath(
|
||||
site.updateInfo.analyticsLog,
|
||||
`${label}.updateInfo.analyticsLog`
|
||||
);
|
||||
if (/\.(?:\d+|gz)$/i.test(path.basename(updateInfoLog))) {
|
||||
throw new ConfigurationError(
|
||||
`${label}.updateInfo.analyticsLog must identify the current uncompressed log`
|
||||
);
|
||||
}
|
||||
if (analyticsLogPaths.has(updateInfoLog)) {
|
||||
throw new ConfigurationError(`analytics log is registered more than once: ${updateInfoLog}`);
|
||||
}
|
||||
analyticsLogPaths.add(updateInfoLog);
|
||||
const updateMetricsSince = parseIsoDate(site.updateInfo.metricsSince);
|
||||
if (!updateMetricsSince) {
|
||||
throw new ConfigurationError(`${label}.updateInfo.metricsSince must be a valid ISO date`);
|
||||
}
|
||||
|
||||
const reportOutputDirectory = requireAbsolutePath(
|
||||
site.reportOutputDirectory,
|
||||
`${label}.reportOutputDirectory`
|
||||
@@ -190,6 +223,12 @@ function validateRegistry(registry) {
|
||||
activatedOn,
|
||||
publicCounter: site.publicCounter,
|
||||
analyticsLog,
|
||||
websiteMetricsSince,
|
||||
updateInfo: {
|
||||
path: UPDATE_INFO_PATH,
|
||||
analyticsLog: updateInfoLog,
|
||||
metricsSince: updateMetricsSince
|
||||
},
|
||||
reportOutputDirectory,
|
||||
publicJsonPath
|
||||
};
|
||||
@@ -214,6 +253,34 @@ function validateRegistry(registry) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!registry.privateMetrics || typeof registry.privateMetrics !== "object"
|
||||
|| Array.isArray(registry.privateMetrics)) {
|
||||
throw new ConfigurationError("registry.privateMetrics must be an object");
|
||||
}
|
||||
const privateMetricsStatePath = requireAbsolutePath(
|
||||
registry.privateMetrics.statePath,
|
||||
"registry.privateMetrics.statePath"
|
||||
);
|
||||
if (!isWithin(stateDirectory, privateMetricsStatePath)
|
||||
|| privateMetricsStatePath === counterStatePath) {
|
||||
throw new ConfigurationError("private metrics statePath must be a distinct path below stateDirectory");
|
||||
}
|
||||
const privateMetricsReportOutputDirectory = requireAbsolutePath(
|
||||
registry.privateMetrics.reportOutputDirectory,
|
||||
"registry.privateMetrics.reportOutputDirectory"
|
||||
);
|
||||
if (!isWithin(stateDirectory, privateMetricsReportOutputDirectory)
|
||||
|| outputDirectories.has(privateMetricsReportOutputDirectory)
|
||||
|| privateMetricsReportOutputDirectory === combinedReportOutputDirectory) {
|
||||
throw new ConfigurationError("private metrics reportOutputDirectory must be distinct below stateDirectory");
|
||||
}
|
||||
if (registry.privateMetrics.timeZone !== "Europe/Berlin") {
|
||||
throw new ConfigurationError("registry.privateMetrics.timeZone must be Europe/Berlin");
|
||||
}
|
||||
if (registry.privateMetrics.detailRetentionDays !== 14) {
|
||||
throw new ConfigurationError("registry.privateMetrics.detailRetentionDays must be 14");
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
stateDirectory,
|
||||
@@ -226,6 +293,12 @@ function validateRegistry(registry) {
|
||||
combined: {
|
||||
reportOutputDirectory: combinedReportOutputDirectory
|
||||
},
|
||||
privateMetrics: {
|
||||
statePath: privateMetricsStatePath,
|
||||
reportOutputDirectory: privateMetricsReportOutputDirectory,
|
||||
timeZone: "Europe/Berlin",
|
||||
detailRetentionDays: 14
|
||||
},
|
||||
sites
|
||||
};
|
||||
}
|
||||
@@ -304,6 +377,10 @@ function validateInputs(registry, configTemplate) {
|
||||
site.id,
|
||||
resolveAnalyticsLogs(site)
|
||||
]));
|
||||
const updateLogsBySite = new Map(registry.sites.map(site => [
|
||||
site.id,
|
||||
resolveAnalyticsLogs({ analyticsLog: site.updateInfo.analyticsLog })
|
||||
]));
|
||||
|
||||
let geoStats;
|
||||
try {
|
||||
@@ -330,6 +407,10 @@ function validateInputs(registry, configTemplate) {
|
||||
registry.combined.reportOutputDirectory,
|
||||
"combined report output directory"
|
||||
);
|
||||
requireWritableDirectory(
|
||||
registry.privateMetrics.reportOutputDirectory,
|
||||
"private metrics report output directory"
|
||||
);
|
||||
for (const site of registry.sites) {
|
||||
requireWritableDirectory(
|
||||
site.reportOutputDirectory,
|
||||
@@ -343,7 +424,7 @@ function validateInputs(registry, configTemplate) {
|
||||
}
|
||||
}
|
||||
|
||||
return analyticsLogsBySite;
|
||||
return { analyticsLogsBySite, updateLogsBySite };
|
||||
}
|
||||
|
||||
function renderGoAccessConfig(template, dbPath, restore, geoIpCountryDatabase) {
|
||||
@@ -604,6 +685,11 @@ function atomicCopyFile(source, destination) {
|
||||
atomicWriteFile(destination, fs.readFileSync(source));
|
||||
}
|
||||
|
||||
function addPrivateMetricsLink(html) {
|
||||
const link = '<p style="margin:1rem"><a href="/metrics/">Private daily and annual metrics</a></p>';
|
||||
return /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${link}</body>`) : `${html}\n${link}\n`;
|
||||
}
|
||||
|
||||
function replaceDirectory(source, destination) {
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
const backup = `${destination}.previous-${process.pid}`;
|
||||
@@ -720,7 +806,12 @@ function generateReports(options, dependencies = {}) {
|
||||
const checkGoAccess = dependencies.checkGoAccess || defaultCheckGoAccess;
|
||||
const now = dependencies.now ? dependencies.now() : new Date();
|
||||
|
||||
const analyticsLogsBySite = validateInputs(registry, configTemplate);
|
||||
const { analyticsLogsBySite, updateLogsBySite } = validateInputs(registry, configTemplate);
|
||||
try {
|
||||
validateImportOptions(options, registry);
|
||||
} catch (error) {
|
||||
throw new ConfigurationError(error.message);
|
||||
}
|
||||
const goAccess = checkGoAccess(options.goaccessBinary || "goaccess");
|
||||
if (!goAccess || !goAccess.geoIpMmdb) {
|
||||
throw new ConfigurationError(
|
||||
@@ -777,15 +868,24 @@ function generateReports(options, dependencies = {}) {
|
||||
content: `${JSON.stringify(publicPayload(state, site, now), null, 2)}\n`
|
||||
}));
|
||||
|
||||
const privateMetrics = generatePrivateMetrics({
|
||||
registry,
|
||||
analyticsLogsBySite,
|
||||
updateLogsBySite,
|
||||
options,
|
||||
visitState: state,
|
||||
context: {
|
||||
...context,
|
||||
now
|
||||
}
|
||||
});
|
||||
|
||||
if (!options.dryRun) {
|
||||
for (const report of prepared) {
|
||||
atomicCopyFile(
|
||||
report.outputJson,
|
||||
path.join(report.outputDirectory, "report.json")
|
||||
);
|
||||
atomicCopyFile(
|
||||
report.outputHtml,
|
||||
path.join(report.outputDirectory, "report.html")
|
||||
atomicCopyFile(report.outputJson, path.join(report.outputDirectory, "report.json"));
|
||||
atomicWriteFile(
|
||||
path.join(report.outputDirectory, "report.html"),
|
||||
addPrivateMetricsLink(fs.readFileSync(report.outputHtml, "utf8"))
|
||||
);
|
||||
}
|
||||
for (const report of prepared) {
|
||||
@@ -798,6 +898,14 @@ function generateReports(options, dependencies = {}) {
|
||||
for (const publicFile of publicFiles) {
|
||||
atomicWriteFile(publicFile.destination, publicFile.content, 0o644);
|
||||
}
|
||||
atomicWriteFile(
|
||||
registry.privateMetrics.statePath,
|
||||
`${JSON.stringify(privateMetrics.state, null, 2)}\n`
|
||||
);
|
||||
for (const report of privateMetrics.files) {
|
||||
fs.mkdirSync(path.dirname(report.destination), { recursive: true });
|
||||
atomicWriteFile(report.destination, report.content);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -805,7 +913,9 @@ function generateReports(options, dependencies = {}) {
|
||||
dryRun: Boolean(options.dryRun),
|
||||
sites: registry.sites.length,
|
||||
reports: prepared.length,
|
||||
publicCounters: publicFiles.length
|
||||
publicCounters: publicFiles.length,
|
||||
privateMetricReports: privateMetrics.files.length,
|
||||
historicalImport: privateMetrics.imported
|
||||
};
|
||||
} finally {
|
||||
if (runDirectory && fs.existsSync(runDirectory)) {
|
||||
@@ -816,7 +926,12 @@ function generateReports(options, dependencies = {}) {
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = { check: false, dryRun: false, goaccessBinary: "goaccess" };
|
||||
const options = {
|
||||
check: false,
|
||||
dryRun: false,
|
||||
goaccessBinary: "goaccess",
|
||||
importLogs: []
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
@@ -824,7 +939,10 @@ function parseArguments(argv) {
|
||||
options.check = true;
|
||||
} else if (argument === "--dry-run") {
|
||||
options.dryRun = true;
|
||||
} else if (["--registry", "--config-template", "--goaccess"].includes(argument)) {
|
||||
} else if ([
|
||||
"--registry", "--config-template", "--goaccess", "--import-log", "--import-format",
|
||||
"--import-site", "--coverage-from", "--coverage-through"
|
||||
].includes(argument)) {
|
||||
const value = argv[index + 1];
|
||||
if (!value) {
|
||||
throw new ConfigurationError(`${argument} requires a value`);
|
||||
@@ -833,6 +951,11 @@ function parseArguments(argv) {
|
||||
if (argument === "--registry") options.registryPath = value;
|
||||
if (argument === "--config-template") options.configTemplatePath = value;
|
||||
if (argument === "--goaccess") options.goaccessBinary = value;
|
||||
if (argument === "--import-log") options.importLogs.push(value);
|
||||
if (argument === "--import-format") options.importFormat = value;
|
||||
if (argument === "--import-site") options.importSite = value;
|
||||
if (argument === "--coverage-from") options.coverageFrom = value;
|
||||
if (argument === "--coverage-through") options.coverageThrough = value;
|
||||
} else {
|
||||
throw new ConfigurationError(`unknown argument: ${argument}`);
|
||||
}
|
||||
@@ -841,7 +964,9 @@ function parseArguments(argv) {
|
||||
if (!options.registryPath || !options.configTemplatePath) {
|
||||
throw new ConfigurationError(
|
||||
"usage: generate-reports.js --registry FILE --config-template FILE "
|
||||
+ "[--goaccess FILE] [--check|--dry-run]"
|
||||
+ "[--goaccess FILE] [--check|--dry-run] "
|
||||
+ "[--import-log FILE ... --import-format nginx-combined|analytics-tsv "
|
||||
+ "--import-site ID --coverage-from DATE --coverage-through DATE]"
|
||||
);
|
||||
}
|
||||
if (options.check && options.dryRun) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/var/log/nginx/*-analytics.log {
|
||||
/var/log/nginx/*-analytics.log /var/log/nginx/*-update-information.log {
|
||||
daily
|
||||
rotate 14
|
||||
missingok
|
||||
|
||||
@@ -34,3 +34,10 @@ map "$hamradioonline_analytics_method:$hamradioonline_analytics_path:$hamradioon
|
||||
default 0;
|
||||
"1:1:0" 1;
|
||||
}
|
||||
|
||||
# Count the update-information file separately. This deliberately does not
|
||||
# filter user agents: browsers and bots can fetch the file too.
|
||||
map "$request_method:$uri:$status" $hamradioonline_update_information_loggable {
|
||||
default 0;
|
||||
"GET:/kst4ContestVersionInfo.xml:200" 1;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ log_format hamradioonline_analytics
|
||||
# Include the maps below in the Nginx http context as well.
|
||||
include /etc/nginx/snippets/hamradioonline-analytics-filters.conf;
|
||||
|
||||
# Add this extra log to each registered project server block. Keep the
|
||||
# Add these extra logs to each registered project server block. Keep the
|
||||
# existing operational access_log directive; do not replace it implicitly.
|
||||
#
|
||||
# access_log /var/log/nginx/kst4contest-analytics.log
|
||||
# hamradioonline_analytics if=$hamradioonline_analytics_loggable;
|
||||
|
||||
# access_log /var/log/nginx/kst4contest-update-information.log
|
||||
# hamradioonline_analytics if=$hamradioonline_update_information_loggable;
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const zlib = require("node:zlib");
|
||||
|
||||
const PRIVATE_STATE_SCHEMA_VERSION = 1;
|
||||
const UPDATE_INFO_PATH = "/kst4ContestVersionInfo.xml";
|
||||
const UNKNOWN_COUNTRY = "Unknown";
|
||||
const MAX_IMPORT_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
function parseIsoDate(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
|
||||
if (!match) return null;
|
||||
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||||
return date.getUTCFullYear() === Number(match[1])
|
||||
&& date.getUTCMonth() === Number(match[2]) - 1
|
||||
&& date.getUTCDate() === Number(match[3])
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function dateRange(from, through) {
|
||||
const result = [];
|
||||
const current = new Date(`${from}T12:00:00Z`);
|
||||
const end = new Date(`${through}T12:00:00Z`);
|
||||
while (current <= end) {
|
||||
result.push(current.toISOString().slice(0, 10));
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function shiftDate(date, days) {
|
||||
const value = new Date(`${date}T12:00:00Z`);
|
||||
value.setUTCDate(value.getUTCDate() + days);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function localParts(timestamp, timeZone) {
|
||||
const instant = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||
if (Number.isNaN(instant.getTime())) return null;
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23"
|
||||
}).formatToParts(instant).filter(part => part.type !== "literal")
|
||||
.map(part => [part.type, part.value]));
|
||||
return { date: `${parts.year}-${parts.month}-${parts.day}`, hour: parts.hour };
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
if (typeof value !== "string" || /[\r\n\0]/.test(value)) return null;
|
||||
const withoutQuery = value.split(/[?#]/, 1)[0];
|
||||
if (!withoutQuery.startsWith("/")) return null;
|
||||
const collapsed = withoutQuery.replace(/\/{2,}/g, "/");
|
||||
const trailingSlash = collapsed.length > 1 && collapsed.endsWith("/");
|
||||
const normalized = path.posix.normalize(collapsed);
|
||||
return trailingSlash && normalized !== "/" && !normalized.endsWith("/")
|
||||
? `${normalized}/`
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function unescapeNginx(value) {
|
||||
return value.replace(/\\x([0-9A-Fa-f]{2})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
.replace(/\\"/g, "\"")
|
||||
.replace(/\\\\/g, "\\");
|
||||
}
|
||||
|
||||
function parseAnalyticsLine(line, timeZone = "Europe/Berlin") {
|
||||
const fields = line.split("\t");
|
||||
if (fields.length !== 9) return null;
|
||||
const status = Number(fields[6]);
|
||||
const bytes = fields[7] === "-" ? 0 : Number(fields[7]);
|
||||
const local = localParts(fields[2], timeZone);
|
||||
const requestPath = normalizePath(fields[4]);
|
||||
if (!local || !requestPath || !/^\d{3}$/.test(fields[6])
|
||||
|| !Number.isSafeInteger(bytes) || bytes < 0) return null;
|
||||
let userAgent = fields[8];
|
||||
if (userAgent.startsWith("\"") && userAgent.endsWith("\"")) {
|
||||
userAgent = userAgent.slice(1, -1);
|
||||
}
|
||||
const record = {
|
||||
host: fields[0],
|
||||
ip: fields[1],
|
||||
timestamp: fields[2],
|
||||
date: local.date,
|
||||
hour: local.hour,
|
||||
method: fields[3],
|
||||
path: requestPath,
|
||||
protocol: fields[5],
|
||||
status,
|
||||
bytes,
|
||||
userAgent: unescapeNginx(userAgent)
|
||||
};
|
||||
record.line = toAnalyticsLine(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
const NGINX_MONTHS = {
|
||||
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12"
|
||||
};
|
||||
|
||||
function parseCombinedLine(line, hostname, timeZone = "Europe/Berlin") {
|
||||
const match = /^(\S+) \S+ \S+ \[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})\] "([A-Z]+) ([^ ]+) ([^"]+)" (\d{3}) (\d+|-) "(?:[^"\\]|\\.)*" "((?:[^"\\]|\\.)*)"(?: .*)?$/.exec(line);
|
||||
if (!match || !NGINX_MONTHS[match[3]]) return null;
|
||||
const timestamp = `${match[4]}-${NGINX_MONTHS[match[3]]}-${match[2]}T${match[5]}:${match[6]}:${match[7]}${match[8]}`;
|
||||
const local = localParts(timestamp, timeZone);
|
||||
const requestPath = normalizePath(match[10]);
|
||||
const status = Number(match[12]);
|
||||
const bytes = match[13] === "-" ? 0 : Number(match[13]);
|
||||
if (!local || !requestPath || !Number.isSafeInteger(bytes) || bytes < 0) return null;
|
||||
const userAgent = unescapeNginx(match[14]);
|
||||
const canonicalTimestamp = timestamp.replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
|
||||
return {
|
||||
host: hostname,
|
||||
ip: match[1],
|
||||
timestamp: canonicalTimestamp,
|
||||
date: local.date,
|
||||
hour: local.hour,
|
||||
method: match[9],
|
||||
path: requestPath,
|
||||
protocol: match[11],
|
||||
status,
|
||||
bytes,
|
||||
userAgent,
|
||||
line: toAnalyticsLine({
|
||||
host: hostname,
|
||||
ip: match[1],
|
||||
timestamp: canonicalTimestamp,
|
||||
method: match[9],
|
||||
path: requestPath,
|
||||
protocol: match[11],
|
||||
status,
|
||||
bytes,
|
||||
userAgent
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function escapeLogField(value) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
|
||||
.replace(/[\t\r\n]/g, character => `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
function toAnalyticsLine(record) {
|
||||
return [record.host, record.ip, record.timestamp, record.method, record.path,
|
||||
record.protocol, record.status, record.bytes, `"${escapeLogField(record.userAgent)}"`].join("\t");
|
||||
}
|
||||
|
||||
function isKnownBot(userAgent) {
|
||||
return /(?:bot|crawler|spider|slurp|headless|monitor|healthcheck|uptime|wget|curl)/i.test(userAgent);
|
||||
}
|
||||
|
||||
function isEligibleWebsiteRequest(record) {
|
||||
if (record.method !== "GET" || isKnownBot(record.userAgent)) return false;
|
||||
if (["/visitor-count.json", UPDATE_INFO_PATH, "/sitemap.xml", "/robots.txt", "/favicon.ico",
|
||||
"/assets/favicon.svg", "/health", "/healthz", "/ping", "/status"].includes(record.path)) {
|
||||
return false;
|
||||
}
|
||||
return !/^\/(?:assets|manual\/assets)\//i.test(record.path)
|
||||
&& !/\.(?:css|js|mjs|map|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|xml|txt|pdf|zip|gz|wasm|mp4|webm)$/i.test(record.path);
|
||||
}
|
||||
|
||||
function isUpdateRequest(record) {
|
||||
return record.method === "GET" && record.status === 200 && record.path === UPDATE_INFO_PATH;
|
||||
}
|
||||
|
||||
function clientGroup(userAgent) {
|
||||
if (!userAgent || userAgent === "-") return "Missing user agent";
|
||||
if (/KST4Contest/i.test(userAgent)) return "KST4Contest (explicit)";
|
||||
if (/(?:Edg|Edge)\//i.test(userAgent)) return "Microsoft Edge";
|
||||
if (/Firefox\//i.test(userAgent)) return "Firefox";
|
||||
if (/(?:Chrome|Chromium)\//i.test(userAgent)) return "Chrome/Chromium";
|
||||
if (/Safari\//i.test(userAgent)) return "Safari";
|
||||
if (/^Java\//i.test(userAgent)) return "Java runtime (application unknown)";
|
||||
if (/curl\//i.test(userAgent)) return "curl";
|
||||
if (/Wget\//i.test(userAgent)) return "Wget";
|
||||
if (/(?:bot|crawler|spider|slurp)/i.test(userAgent)) return "Bot/crawler";
|
||||
return "Other or unrecognised";
|
||||
}
|
||||
|
||||
function readLogFile(filePath) {
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isFile()) throw new Error(`log input is not a file: ${filePath}`);
|
||||
if (stats.size > MAX_IMPORT_BYTES) throw new Error(`log input exceeds the 1 GiB safety limit: ${filePath}`);
|
||||
const content = fs.readFileSync(filePath);
|
||||
try {
|
||||
return filePath.toLowerCase().endsWith(".gz")
|
||||
? zlib.gunzipSync(content, { maxOutputLength: MAX_IMPORT_BYTES }).toString("utf8")
|
||||
: content.toString("utf8");
|
||||
} catch (error) {
|
||||
throw new Error(`could not read compressed log ${filePath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLogFiles(filePaths, parser, { deduplicateAcrossFiles = false, label = "log" } = {}) {
|
||||
const parsedFiles = [];
|
||||
for (const filePath of filePaths) {
|
||||
const records = [];
|
||||
const lines = readLogFile(filePath).split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (lines[index] === "" && index === lines.length - 1) continue;
|
||||
if (lines[index].trim() === "") continue;
|
||||
const record = parser(lines[index]);
|
||||
if (!record) throw new Error(`${label} has an invalid line at ${filePath}:${index + 1}`);
|
||||
records.push(record);
|
||||
}
|
||||
parsedFiles.push(records);
|
||||
}
|
||||
if (!deduplicateAcrossFiles) return parsedFiles.flat();
|
||||
|
||||
const maxima = new Map();
|
||||
for (const records of parsedFiles) {
|
||||
const counts = new Map();
|
||||
for (const record of records) counts.set(record.line, (counts.get(record.line) || 0) + 1);
|
||||
for (const [line, count] of counts) maxima.set(line, Math.max(maxima.get(line) || 0, count));
|
||||
}
|
||||
const byLine = new Map(parsedFiles.flat().map(record => [record.line, record]));
|
||||
const result = [];
|
||||
for (const [line, count] of maxima) {
|
||||
for (let index = 0; index < count; index += 1) result.push({ ...byLine.get(line) });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addCount(values, key, count) {
|
||||
const value = (Object.hasOwn(values, key) ? values[key] : 0) + count;
|
||||
Object.defineProperty(values, key, {
|
||||
value,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
function countPanel(report, panelName) {
|
||||
const panel = report[panelName];
|
||||
if (!panel || !Array.isArray(panel.data)) throw new Error(`GoAccess JSON report has no ${panelName} panel`);
|
||||
const values = {};
|
||||
for (const row of panel.data) {
|
||||
const rawName = typeof row.data === "string" ? row.data.trim() : "";
|
||||
const name = !rawName || /^(?:unknown|n\/a|-|\(not set\))$/i.test(rawName)
|
||||
? UNKNOWN_COUNTRY
|
||||
: rawName;
|
||||
const count = row && row.hits && row.hits.count;
|
||||
if (!Number.isSafeInteger(count) || count < 0) throw new Error(`GoAccess ${panelName} panel contains invalid data`);
|
||||
addCount(values, name, count);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function countCountries(report) {
|
||||
const panel = report.geolocation;
|
||||
if (!panel || !Array.isArray(panel.data)) {
|
||||
throw new Error("GoAccess JSON report has no geolocation panel");
|
||||
}
|
||||
const rows = panel.data.flatMap(row => Array.isArray(row.items) && row.items.length
|
||||
? row.items
|
||||
: [row]);
|
||||
return countPanel({ geolocation: { data: rows } }, "geolocation");
|
||||
}
|
||||
|
||||
function sumValues(values) {
|
||||
return Object.values(values).reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
|
||||
function aggregateGoAccessReport(report, kind) {
|
||||
const total = report && report.general && report.general.total_requests;
|
||||
if (!Number.isSafeInteger(total) || total < 0) throw new Error("GoAccess report contains an invalid total request count");
|
||||
const countries = countCountries(report);
|
||||
const countryTotal = sumValues(countries);
|
||||
if (countryTotal > total) throw new Error("GoAccess country total exceeds the request total");
|
||||
if (countryTotal < total) addCount(countries, UNKNOWN_COUNTRY, total - countryTotal);
|
||||
|
||||
if (kind === "updateInfo") return { requests: total, countries };
|
||||
const paths = countPanel(report, "requests");
|
||||
if (sumValues(paths) !== total) {
|
||||
throw new Error("website page total differs from the GoAccess request-panel definition");
|
||||
}
|
||||
return { pageViews: total, countries, paths };
|
||||
}
|
||||
|
||||
function renderMetricsConfig(template, dbPath, geoIpCountryDatabase, includeCrawlers) {
|
||||
let rendered = template
|
||||
.replaceAll("{{DB_PATH}}", dbPath)
|
||||
.replaceAll("{{RESTORE_DIRECTIVE}}", "# private daily aggregation uses a fresh database")
|
||||
.replaceAll("{{GEOIP_COUNTRY_DATABASE}}", geoIpCountryDatabase)
|
||||
.replace(/^max-items\s+\d+$/m, "max-items 1000000")
|
||||
.replace(/^persist true$/m, "# persistence is disabled for private daily aggregation");
|
||||
if (includeCrawlers) {
|
||||
rendered = rendered.replace(/^ignore-crawlers true$/m, "ignore-crawlers false")
|
||||
.replace(/^unknowns-as-crawlers true$/m, "unknowns-as-crawlers false");
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function aggregateDays({ records, dates, kind, site, context }) {
|
||||
const byDate = new Map(dates.map(date => [date, []]));
|
||||
for (const record of records) {
|
||||
if (byDate.has(record.date)) byDate.get(record.date).push(record);
|
||||
}
|
||||
const daily = {};
|
||||
for (const date of dates) {
|
||||
const dayRecords = byDate.get(date);
|
||||
if (dayRecords.length === 0) {
|
||||
daily[date] = kind === "website"
|
||||
? { pageViews: 0, countries: {}, paths: {} }
|
||||
: { requests: 0, countries: {}, hours: {}, clients: {} };
|
||||
continue;
|
||||
}
|
||||
const jobId = `metrics-${site.id}-${kind}-${date}`;
|
||||
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, `${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,
|
||||
date,
|
||||
outputJson,
|
||||
dbPath
|
||||
});
|
||||
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);
|
||||
if (kind === "updateInfo") {
|
||||
const hours = {};
|
||||
const clients = {};
|
||||
for (const record of dayRecords) {
|
||||
addCount(hours, record.hour, 1);
|
||||
const group = clientGroup(record.userAgent);
|
||||
addCount(clients, group, 1);
|
||||
}
|
||||
if (sumValues(hours) !== daily[date].requests || sumValues(clients) !== daily[date].requests) {
|
||||
throw new Error(`update detail total differs from GoAccess for ${site.id}/${date}`);
|
||||
}
|
||||
daily[date].hours = hours;
|
||||
daily[date].clients = clients;
|
||||
}
|
||||
}
|
||||
return daily;
|
||||
}
|
||||
|
||||
function loadState(statePath) {
|
||||
if (!fs.existsSync(statePath)) return { schemaVersion: PRIVATE_STATE_SCHEMA_VERSION, sites: {} };
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(`private metrics state is not valid JSON: ${error.message}`);
|
||||
}
|
||||
if (!state || state.schemaVersion !== PRIVATE_STATE_SCHEMA_VERSION || !state.sites
|
||||
|| typeof state.sites !== "object" || Array.isArray(state.sites)) {
|
||||
throw new Error("private metrics state has an unsupported structure");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function containsAtLeast(candidate, current) {
|
||||
const keys = new Set([...Object.keys(candidate), ...Object.keys(current)]);
|
||||
return [...keys].every(key => (candidate[key] || 0) >= (current[key] || 0));
|
||||
}
|
||||
|
||||
function sameCounts(left, right) {
|
||||
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
||||
return [...keys].every(key => (left[key] || 0) === (right[key] || 0));
|
||||
}
|
||||
|
||||
function mergeDay(current, candidate, totalKey, label) {
|
||||
if (!current) return candidate;
|
||||
const currentTotal = current[totalKey];
|
||||
const candidateTotal = candidate[totalKey];
|
||||
if (candidateTotal === currentTotal) {
|
||||
if (!sameCounts(current.countries, candidate.countries)
|
||||
|| (totalKey === "pageViews" && !sameCounts(current.paths, candidate.paths))) {
|
||||
throw new Error(`overlapping ${label} aggregates disagree at equal totals`);
|
||||
}
|
||||
return { ...candidate, ...(current.hours ? { hours: current.hours, clients: current.clients } : {}) };
|
||||
}
|
||||
const candidateLarger = candidateTotal > currentTotal;
|
||||
const larger = candidateLarger ? candidate : current;
|
||||
const smaller = candidateLarger ? current : candidate;
|
||||
if (!containsAtLeast(larger.countries, smaller.countries)
|
||||
|| (totalKey === "pageViews" && !containsAtLeast(larger.paths, smaller.paths))) {
|
||||
throw new Error(`overlapping ${label} aggregates are not monotonic`);
|
||||
}
|
||||
return larger;
|
||||
}
|
||||
|
||||
function mergeSeries(target, incoming, totalKey, label) {
|
||||
for (const [date, candidate] of Object.entries(incoming)) {
|
||||
target[date] = mergeDay(target[date], candidate, totalKey, `${label}/${date}`);
|
||||
}
|
||||
}
|
||||
|
||||
function purgeDetails(siteState, today, retentionDays) {
|
||||
const firstRetained = shiftDate(today, -(retentionDays - 1));
|
||||
for (const [date, value] of Object.entries(siteState.updateInfo.daily)) {
|
||||
if (date < firstRetained) {
|
||||
delete value.hours;
|
||||
delete value.clients;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, character => ({
|
||||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"
|
||||
})[character]);
|
||||
}
|
||||
|
||||
function page(title, body) {
|
||||
return `<!doctype html>\n<html lang="en"><head><meta charset="utf-8">\n`
|
||||
+ `<meta name="viewport" content="width=device-width,initial-scale=1">\n`
|
||||
+ `<meta name="robots" content="noindex,nofollow"><title>${escapeHtml(title)}</title>\n`
|
||||
+ `<style>body{font:16px/1.5 system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;color:#18202a}`
|
||||
+ `nav a{margin-right:1rem}table{border-collapse:collapse;width:100%;margin:1rem 0 2rem}`
|
||||
+ `th,td{border:1px solid #ccd2d8;padding:.35rem .5rem;text-align:left;vertical-align:top}`
|
||||
+ `th{background:#eef1f4}td.num{text-align:right;font-variant-numeric:tabular-nums}`
|
||||
+ `.note{color:#4a5560}.scroll{overflow-x:auto}</style></head><body>`
|
||||
+ `<nav><a href="/combined/">GoAccess combined</a><a href="/metrics/">Private metrics</a></nav>`
|
||||
+ body + `</body></html>\n`;
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
return `<div class="scroll"><table><thead><tr>${headers.map(value => `<th>${escapeHtml(value)}</th>`).join("")}</tr></thead>`
|
||||
+ `<tbody>${rows.length ? rows.map(row => `<tr>${row.map((value, index) => `<td${index === row.length - 1 ? " class=\"num\"" : ""}>${escapeHtml(value)}</td>`).join("")}</tr>`).join("") : `<tr><td colspan="${headers.length}">No data</td></tr>`}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function sortedEntries(values) {
|
||||
return Object.entries(values).sort(([left], [right]) => left.localeCompare(right, "en"));
|
||||
}
|
||||
|
||||
function annualTotals(daily, totalKey) {
|
||||
const years = {};
|
||||
for (const [date, value] of Object.entries(daily)) {
|
||||
const year = date.slice(0, 4);
|
||||
if (!years[year]) years[year] = { total: 0, countries: {}, paths: {} };
|
||||
years[year].total += value[totalKey];
|
||||
for (const [country, count] of Object.entries(value.countries)) {
|
||||
addCount(years[year].countries, country, count);
|
||||
}
|
||||
for (const [requestPath, count] of Object.entries(value.paths || {})) {
|
||||
addCount(years[year].paths, requestPath, count);
|
||||
}
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
function coverageText(series) {
|
||||
return series.firstCoveredOn
|
||||
? `The first covered day is ${escapeHtml(series.firstCoveredOn)}. Earlier dates are unknown, not zero.`
|
||||
: "No covered day has been recorded yet.";
|
||||
}
|
||||
|
||||
function renderDaily(site, kind, visitSite = null) {
|
||||
const series = site[kind];
|
||||
const dailyVisits = visitSite ? visitSite.dailyVisits : {};
|
||||
const totalKey = kind === "website" ? "pageViews" : "requests";
|
||||
const label = kind === "website" ? "Website page views" : "Update-information requests";
|
||||
const dates = [...new Set([
|
||||
...Object.keys(series.daily),
|
||||
...(kind === "website" ? Object.keys(dailyVisits) : [])
|
||||
])].sort();
|
||||
const totals = dates.map(date => kind === "website"
|
||||
? [date, series.daily[date] ? series.daily[date][totalKey] : "unknown", dailyVisits[date] ?? "unknown"]
|
||||
: [date, series.daily[date][totalKey]]);
|
||||
const metricDates = Object.keys(series.daily).sort();
|
||||
const countries = metricDates.flatMap(date => sortedEntries(series.daily[date].countries)
|
||||
.map(([country, count]) => [date, country, count]));
|
||||
let details = table(kind === "website"
|
||||
? ["Date", label, "Approximate visits"]
|
||||
: ["Date", label], totals) + `<h2>Countries by day</h2>`
|
||||
+ table(["Date", "Country", kind === "website" ? "Page views" : "Requests"], countries);
|
||||
if (kind === "website") {
|
||||
const paths = metricDates.flatMap(date => sortedEntries(series.daily[date].paths)
|
||||
.map(([requestPath, count]) => [date, requestPath, count]));
|
||||
details += `<h2>Pages by day</h2>${table(["Date", "Path", "Page views"], paths)}`;
|
||||
} else {
|
||||
const hours = dates.flatMap(date => sortedEntries(series.daily[date].hours || {})
|
||||
.map(([hour, count]) => [date, `${hour}:00–${hour}:59`, count]));
|
||||
const clients = dates.flatMap(date => sortedEntries(series.daily[date].clients || {})
|
||||
.map(([client, count]) => [date, client, count]));
|
||||
details += `<h2>Hourly detail (last 14 days)</h2>${table(["Date", "Hour (Europe/Berlin)", "Requests"], hours)}`
|
||||
+ `<h2>Recognisable client groups (last 14 days)</h2>`
|
||||
+ `<p class="note">Existing KST4Contest versions do not send a reliable application-specific user agent. A Java user agent therefore identifies only a Java runtime, not a KST4Contest start or user.</p>`
|
||||
+ table(["Date", "Client group", "Requests"], clients);
|
||||
}
|
||||
const visitCoverage = kind === "website" && visitSite
|
||||
? ` Approximate visits have their own coverage beginning ${escapeHtml(visitSite.since)}.`
|
||||
: "";
|
||||
return page(`${label} by day`, `<h1>${label}: daily view</h1><p class="note">${coverageText(series)}${visitCoverage}</p>${details}`);
|
||||
}
|
||||
|
||||
function renderAnnual(site, kind, visitSite = null) {
|
||||
const series = site[kind];
|
||||
const dailyVisits = visitSite ? visitSite.dailyVisits : {};
|
||||
const totalKey = kind === "website" ? "pageViews" : "requests";
|
||||
const label = kind === "website" ? "Website page views" : "Update-information requests";
|
||||
const years = annualTotals(series.daily, totalKey);
|
||||
const visitYears = {};
|
||||
for (const [date, count] of Object.entries(dailyVisits)) {
|
||||
visitYears[date.slice(0, 4)] = (visitYears[date.slice(0, 4)] || 0) + count;
|
||||
}
|
||||
const allYears = [...new Set([
|
||||
...Object.keys(years),
|
||||
...(kind === "website" ? Object.keys(visitYears) : [])
|
||||
])].sort();
|
||||
const summary = allYears.map(year => kind === "website"
|
||||
? [year, years[year] ? years[year].total : "unknown", visitYears[year] ?? "unknown"]
|
||||
: [year, years[year].total]);
|
||||
const allDates = [...new Set([
|
||||
...Object.keys(series.daily),
|
||||
...(kind === "website" ? Object.keys(dailyVisits) : [])
|
||||
])].sort();
|
||||
const trend = allDates.map(date => kind === "website"
|
||||
? [date, series.daily[date] ? series.daily[date][totalKey] : "unknown", dailyVisits[date] ?? "unknown"]
|
||||
: [date, series.daily[date][totalKey]]);
|
||||
const countries = sortedEntries(years).flatMap(([year, value]) => sortedEntries(value.countries)
|
||||
.map(([country, count]) => [year, country, count]));
|
||||
let details = table(kind === "website"
|
||||
? ["Year", label, "Approximate visits"]
|
||||
: ["Year", label], summary) + `<h2>Daily values by year</h2>`
|
||||
+ table(kind === "website"
|
||||
? ["Date", label, "Approximate visits"]
|
||||
: ["Date", label], trend) + `<h2>Country totals by year</h2>`
|
||||
+ table(["Year", "Country", kind === "website" ? "Page views" : "Requests"], countries);
|
||||
if (kind === "website") {
|
||||
const paths = sortedEntries(years).flatMap(([year, value]) => sortedEntries(value.paths)
|
||||
.map(([requestPath, count]) => [year, requestPath, count]));
|
||||
details += `<h2>Page totals by year</h2>${table(["Year", "Path", "Page views"], paths)}`;
|
||||
}
|
||||
const visitCoverage = kind === "website" && visitSite
|
||||
? ` Approximate visits have their own coverage beginning ${escapeHtml(visitSite.since)}.`
|
||||
: "";
|
||||
return page(`${label} by year`, `<h1>${label}: annual view</h1><p class="note">${coverageText(series)}${visitCoverage}</p>${details}`);
|
||||
}
|
||||
|
||||
function renderReports(state, registry, now, visitState = { sites: {} }) {
|
||||
const files = [];
|
||||
const siteLinks = [];
|
||||
for (const siteConfig of registry.sites) {
|
||||
const site = state.sites[siteConfig.id];
|
||||
const visitSite = Object.hasOwn(visitState.sites, siteConfig.id)
|
||||
? visitState.sites[siteConfig.id]
|
||||
: null;
|
||||
const base = path.join(registry.privateMetrics.reportOutputDirectory, siteConfig.id);
|
||||
files.push({ destination: path.join(base, "website", "daily", "report.html"), content: renderDaily(site, "website", visitSite) });
|
||||
files.push({ destination: path.join(base, "website", "yearly", "report.html"), content: renderAnnual(site, "website", visitSite) });
|
||||
files.push({ destination: path.join(base, "updates", "daily", "report.html"), content: renderDaily(site, "updateInfo") });
|
||||
files.push({ destination: path.join(base, "updates", "yearly", "report.html"), content: renderAnnual(site, "updateInfo") });
|
||||
siteLinks.push(`<h2>${escapeHtml(siteConfig.hostname)}</h2><ul>`
|
||||
+ `<li><a href="/metrics/${encodeURIComponent(siteConfig.id)}/website/daily/">Website: daily</a></li>`
|
||||
+ `<li><a href="/metrics/${encodeURIComponent(siteConfig.id)}/website/yearly/">Website: annual</a></li>`
|
||||
+ `<li><a href="/metrics/${encodeURIComponent(siteConfig.id)}/updates/daily/">Update information: daily</a></li>`
|
||||
+ `<li><a href="/metrics/${encodeURIComponent(siteConfig.id)}/updates/yearly/">Update information: annual</a></li></ul>`);
|
||||
}
|
||||
files.push({
|
||||
destination: path.join(registry.privateMetrics.reportOutputDirectory, "report.html"),
|
||||
content: page("Private website metrics", `<h1>Private website metrics</h1>`
|
||||
+ `<p>Generated ${escapeHtml(now.toISOString())}. Visits, page views and update-information requests are separate measures.</p>`
|
||||
+ `<p class="note">An update-information request is a successful GET request for the exact XML path. Browsers and bots may request it too; it is neither a program-start count nor a user count.</p>${siteLinks.join("")}`)
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
function validateImportOptions(options, registry) {
|
||||
const hasImport = Array.isArray(options.importLogs) && options.importLogs.length > 0;
|
||||
const companions = [options.importFormat, options.importSite, options.coverageFrom, options.coverageThrough];
|
||||
if (!hasImport && companions.some(Boolean)) throw new Error("historical import options require at least one --import-log");
|
||||
if (!hasImport) return null;
|
||||
if (!options.importFormat || !["nginx-combined", "analytics-tsv"].includes(options.importFormat)) {
|
||||
throw new Error("--import-format must be nginx-combined or analytics-tsv");
|
||||
}
|
||||
const site = registry.sites.find(entry => entry.id === options.importSite);
|
||||
if (!site) throw new Error("--import-site must identify a registered site");
|
||||
if (!parseIsoDate(options.coverageFrom) || !parseIsoDate(options.coverageThrough)
|
||||
|| options.coverageFrom > options.coverageThrough) {
|
||||
throw new Error("--coverage-from and --coverage-through must define a valid inclusive range");
|
||||
}
|
||||
const importLogs = options.importLogs.map(filePath => path.resolve(filePath));
|
||||
for (const filePath of importLogs) fs.accessSync(filePath, fs.constants.R_OK);
|
||||
return { ...options, site, importLogs };
|
||||
}
|
||||
|
||||
function validCountMap(values) {
|
||||
return values && typeof values === "object" && !Array.isArray(values)
|
||||
&& Object.entries(values).every(([key, value]) => key !== ""
|
||||
&& Number.isSafeInteger(value) && value >= 0);
|
||||
}
|
||||
|
||||
function validateStoredSeries(series, totalKey, label) {
|
||||
if (!series || typeof series !== "object" || !parseIsoDate(series.liveSince)
|
||||
|| (series.firstCoveredOn !== null && !parseIsoDate(series.firstCoveredOn))
|
||||
|| (series.lastSuccessfulOn !== null && !parseIsoDate(series.lastSuccessfulOn))
|
||||
|| !series.daily || typeof series.daily !== "object" || Array.isArray(series.daily)) {
|
||||
throw new Error(`private metrics state for ${label} is invalid`);
|
||||
}
|
||||
for (const [date, value] of Object.entries(series.daily)) {
|
||||
if (!parseIsoDate(date) || !value || !Number.isSafeInteger(value[totalKey])
|
||||
|| value[totalKey] < 0 || !validCountMap(value.countries)
|
||||
|| sumValues(value.countries) !== value[totalKey]) {
|
||||
throw new Error(`private metrics state value for ${label}/${date} is invalid`);
|
||||
}
|
||||
if (totalKey === "pageViews") {
|
||||
if (!validCountMap(value.paths) || sumValues(value.paths) !== value[totalKey]) {
|
||||
throw new Error(`private metrics path value for ${label}/${date} is invalid`);
|
||||
}
|
||||
} else if ((value.hours !== undefined || value.clients !== undefined)
|
||||
&& (!validCountMap(value.hours) || !validCountMap(value.clients)
|
||||
|| sumValues(value.hours) !== value[totalKey]
|
||||
|| sumValues(value.clients) !== value[totalKey])) {
|
||||
throw new Error(`private metrics detail value for ${label}/${date} is invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSiteState(state, site) {
|
||||
const current = Object.hasOwn(state.sites, site.id) ? state.sites[site.id] : null;
|
||||
if (current) {
|
||||
validateStoredSeries(current.website, "pageViews", `${site.id}/website`);
|
||||
validateStoredSeries(current.updateInfo, "requests", `${site.id}/update information`);
|
||||
if (current.hostname !== site.hostname
|
||||
|| current.website.liveSince !== site.websiteMetricsSince
|
||||
|| current.updateInfo.liveSince !== site.updateInfo.metricsSince) {
|
||||
throw new Error(`private metrics identity for ${site.id} differs from existing state`);
|
||||
}
|
||||
}
|
||||
if (!current) {
|
||||
state.sites[site.id] = {
|
||||
hostname: site.hostname,
|
||||
website: { liveSince: site.websiteMetricsSince, firstCoveredOn: null, lastSuccessfulOn: null, daily: {} },
|
||||
updateInfo: { liveSince: site.updateInfo.metricsSince, firstCoveredOn: null, lastSuccessfulOn: null, daily: {} }
|
||||
};
|
||||
}
|
||||
return state.sites[site.id];
|
||||
}
|
||||
|
||||
function liveDates(series, today, historicalBridge = null) {
|
||||
// Re-read the last successful day as well. The first run after midnight must
|
||||
// still pick up requests which arrived after the final run of the previous day.
|
||||
const start = series.lastSuccessfulOn || series.liveSince;
|
||||
const oldestAvailable = shiftDate(today, -1);
|
||||
if (start < oldestAvailable) {
|
||||
const dayBeforeHandover = shiftDate(oldestAvailable, -1);
|
||||
const bridgeIsComplete = historicalBridge
|
||||
&& historicalBridge.coverageFrom <= start
|
||||
&& historicalBridge.coverageThrough >= dayBeforeHandover;
|
||||
if (!bridgeIsComplete) {
|
||||
throw new Error(
|
||||
`private metrics gap begins ${start}; regular current/.1 processing starts no earlier than ${oldestAvailable}`
|
||||
);
|
||||
}
|
||||
return dateRange(oldestAvailable, today);
|
||||
}
|
||||
return dateRange(start > today ? today : start, today);
|
||||
}
|
||||
|
||||
function generatePrivateMetrics({ registry, analyticsLogsBySite, updateLogsBySite, options, context, visitState }) {
|
||||
const state = loadState(registry.privateMetrics.statePath);
|
||||
const today = localParts(context.now, registry.privateMetrics.timeZone).date;
|
||||
const imported = validateImportOptions(options, registry);
|
||||
if (imported && imported.coverageThrough > today) {
|
||||
throw new Error("historical import coverage must not extend into the future");
|
||||
}
|
||||
|
||||
for (const site of registry.sites) {
|
||||
const siteState = ensureSiteState(state, site);
|
||||
if (site.websiteMetricsSince > today || site.updateInfo.metricsSince > today) {
|
||||
throw new Error(`private metrics live coverage for ${site.id} must not begin in the future`);
|
||||
}
|
||||
const historicalBridge = imported && imported.site.id === site.id ? imported : null;
|
||||
const websiteDates = liveDates(siteState.website, today, historicalBridge);
|
||||
const updateDates = liveDates(siteState.updateInfo, today, historicalBridge);
|
||||
const websiteRecords = parseLogFiles(
|
||||
analyticsLogsBySite.get(site.id),
|
||||
line => parseAnalyticsLine(line, registry.privateMetrics.timeZone),
|
||||
{ label: "website analytics log" }
|
||||
).filter(isEligibleWebsiteRequest);
|
||||
const updateRecords = parseLogFiles(
|
||||
updateLogsBySite.get(site.id),
|
||||
line => parseAnalyticsLine(line, registry.privateMetrics.timeZone),
|
||||
{ label: "update-information log" }
|
||||
).filter(isUpdateRequest);
|
||||
mergeSeries(siteState.website.daily, aggregateDays({
|
||||
records: websiteRecords,
|
||||
dates: websiteDates,
|
||||
kind: "website",
|
||||
site,
|
||||
context
|
||||
}), "pageViews", "website");
|
||||
mergeSeries(siteState.updateInfo.daily, aggregateDays({
|
||||
records: updateRecords,
|
||||
dates: updateDates,
|
||||
kind: "updateInfo",
|
||||
site,
|
||||
context
|
||||
}), "requests", "update information");
|
||||
for (const series of [siteState.website, siteState.updateInfo]) {
|
||||
series.firstCoveredOn = !series.firstCoveredOn || series.liveSince < series.firstCoveredOn ? series.liveSince : series.firstCoveredOn;
|
||||
series.lastSuccessfulOn = today;
|
||||
}
|
||||
}
|
||||
|
||||
if (imported) {
|
||||
const parser = imported.importFormat === "nginx-combined"
|
||||
? line => parseCombinedLine(line, imported.site.hostname, registry.privateMetrics.timeZone)
|
||||
: line => parseAnalyticsLine(line, registry.privateMetrics.timeZone);
|
||||
const allRecords = parseLogFiles(imported.importLogs, parser, { deduplicateAcrossFiles: true, label: "historical import log" });
|
||||
const dates = dateRange(imported.coverageFrom, imported.coverageThrough);
|
||||
const siteState = state.sites[imported.site.id];
|
||||
const website = aggregateDays({ records: allRecords.filter(isEligibleWebsiteRequest), dates, kind: "website", site: imported.site, context });
|
||||
const updates = aggregateDays({ records: allRecords.filter(isUpdateRequest), dates, kind: "updateInfo", site: imported.site, context });
|
||||
mergeSeries(siteState.website.daily, website, "pageViews", "website import");
|
||||
mergeSeries(siteState.updateInfo.daily, updates, "requests", "update-information import");
|
||||
for (const series of [siteState.website, siteState.updateInfo]) {
|
||||
series.firstCoveredOn = !series.firstCoveredOn || imported.coverageFrom < series.firstCoveredOn
|
||||
? imported.coverageFrom : series.firstCoveredOn;
|
||||
}
|
||||
}
|
||||
|
||||
for (const site of Object.values(state.sites)) {
|
||||
purgeDetails(site, today, registry.privateMetrics.detailRetentionDays);
|
||||
}
|
||||
return {
|
||||
state,
|
||||
files: renderReports(state, registry, context.now, visitState),
|
||||
imported: Boolean(imported)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UPDATE_INFO_PATH,
|
||||
aggregateGoAccessReport,
|
||||
clientGroup,
|
||||
escapeHtml,
|
||||
generatePrivateMetrics,
|
||||
isEligibleWebsiteRequest,
|
||||
isUpdateRequest,
|
||||
localParts,
|
||||
mergeDay,
|
||||
normalizePath,
|
||||
parseAnalyticsLine,
|
||||
parseCombinedLine,
|
||||
parseLogFiles,
|
||||
purgeDetails,
|
||||
renderReports,
|
||||
validateImportOptions
|
||||
};
|
||||
@@ -4,6 +4,12 @@
|
||||
"counterStatePath": "/var/lib/hamradioonline-analytics/public-counter-state.json",
|
||||
"lockFile": "/run/hamradioonline-analytics/generator.lock",
|
||||
"geoIpCountryDatabase": "/var/lib/GeoIP/GeoLite2-Country.mmdb",
|
||||
"privateMetrics": {
|
||||
"statePath": "/var/lib/hamradioonline-analytics/private-metrics-state.json",
|
||||
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/metrics",
|
||||
"timeZone": "Europe/Berlin",
|
||||
"detailRetentionDays": 14
|
||||
},
|
||||
"combined": {
|
||||
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/combined"
|
||||
},
|
||||
@@ -13,6 +19,12 @@
|
||||
"hostname": "kst4contest.hamradioonline.de",
|
||||
"analyticsLog": "/var/log/nginx/kst4contest-analytics.log",
|
||||
"activatedOn": "2026-09-11",
|
||||
"websiteMetricsSince": "2026-09-14",
|
||||
"updateInfo": {
|
||||
"path": "/kst4ContestVersionInfo.xml",
|
||||
"analyticsLog": "/var/log/nginx/kst4contest-update-information.log",
|
||||
"metricsSince": "2026-09-14"
|
||||
},
|
||||
"publicCounter": true,
|
||||
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/kst4contest",
|
||||
"publicJsonPath": "/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Generate private GoAccess reports and public project counters
|
||||
Description=Generate private analytics reports and public project counters
|
||||
After=nginx.service
|
||||
|
||||
[Service]
|
||||
|
||||
Reference in New Issue
Block a user