Skip to content
Sign in

Logging and audit

What is logged where (PHP errors, web server logs, the API audit and the change log), what is not, who-what-when queries, retention and audit:prune, the change log tables and privacy notes.

This page explains what a WBengine site records, where, for how long, and how to ask "who changed what and when".

What is logged where

Source Where What
PHP and framework errors PHP error_log Warnings, exceptions, "cache disabled: ..." and one line with a short reference code for every 5xx failure of API v2.
Web server Nginx or Apache access and error logs Every HTTP request: time, address, URL, status. This is the only place with IP addresses.
API v2 requests table cms_api_audit One row per authenticated call: who, which operation, outcome.
Content and structure changes table cms_api_changes One row per write with actor, resource, action and the data before and after.
Article drafts table cms_api_revisions The draft history of an article, used for preview and restore.

PHP error log and reference codes

PHP writes through error_log: the file set by the ini value error_log, otherwise the web server error log (PHP-FPM log, Apache ErrorLog, the Nginx error log for FastCGI errors). Keep display_errors = Off and log_errors = On in production. When an API v2 call fails with a server error (5xx) the client gets a generic message with a short reference code, for example ref:7f3a9c. The same reference is written to error_log with the details and stored in the audit row, so you can search the log for exactly that code.

Web server logs and containers

Nginx and Apache keep the access and error logs, typically /var/log/nginx/ and /var/log/apache2/. In the Docker images Apache sends both logs and the PHP errors to the container output, so they are read with docker compose logs web (or docker logs <container>). The default json-file driver grows without a limit: set max-size and max-file in the Compose logging section or in /etc/docker/daemon.json, or ship the output to a log collector. Host Nginx in front of containers has its own logs in addition.

The audit table cms_api_audit

Columns: audit_id, integration_id (the key), central_actor (the editor label sent by the central Admin, untrusted free text up to 128 characters), local_user_id (the local administrator for key management), action, outcome, detail and created_at (Unix time).

outcome Meaning
allowed The key was valid and had the scope. This includes reads, so reads of content leave a trace here (who, which operation, when) but never the data that was returned.
unauthorized The key was missing, invalid or revoked (HTTP 401). integration_id is empty.
forbidden The key is valid but lacks the required scope (HTTP 403).
rejected An authenticated write was refused: validation, not found, conflict, failed If-Match precondition (412 or 428). detail holds the error code.
error A server failure (5xx). detail is ref:<reference>, the same code as in error_log.

The change log cms_api_changes

Every write path creates one row: change_id, resource (pages, menu, sections, boxes and articles for content, user, cache and audit for the other operations), resource_id, action, before_data and after_data (JSON snapshots), integration_id, central_actor, source, local_user_id and created_at.

source Who wrote Actor
v2 API v2 (the central Admin), in the same transaction as the content change integration_id and central_actor
v1 Older entries written by a retired interface (no new ones) local_user_id, integration_id is 0
cli bin/wbe user:create, user:password, user:delete and audit:prune central_actor is cli:<operating system user>, integration_id is 0

Content resources use plural names (pages, menu, sections, boxes, articles); users, the cache flush and audit pruning use the singular names user, cache and audit, so they never show up in the /changes history of a content resource. Article drafts and restored versions live in cms_api_revisions: revision_id, article_id, payload, action, integration_id, central_actor, created_at. A revision that a draft still points to is never deleted.

What is not logged

  • Request bodies and response bodies. The audit holds the operation and the outcome, the change log holds the resulting data.
  • IP addresses. They exist only in the web server access log.
  • API keys, tokens and passwords. A password change is recorded as passwordChanged: true and nothing else.
  • Public page views. Native analytics (see the settings reference) keeps anonymous daily totals only.

Redaction

Rows written by cli (and older v1 rows) are redacted: any key named password, token, secret, key or hash (also apiKey, key_hash) is stored as [redacted]. Rows from v2 store the data as given because structure recovery replays it, so do not put secrets in page, menu or article content.

Who, what, when

All created_at values are Unix timestamps. Examples:

-- the last 20 changes with who and when
SELECT FROM_UNIXTIME(created_at) AS at, source, resource, resource_id, action,
       COALESCE(central_actor, local_user_id) AS actor, integration_id
FROM cms_api_changes ORDER BY change_id DESC LIMIT 20;

-- the history of one article, with the data
SELECT FROM_UNIXTIME(created_at) AS at, action, central_actor, before_data, after_data
FROM cms_api_changes WHERE resource = 'articles' AND resource_id = '12' ORDER BY change_id;

-- everything one editor did in the last 7 days
SELECT FROM_UNIXTIME(created_at) AS at, resource, resource_id, action
FROM cms_api_changes WHERE central_actor = 'anna@example.com' AND created_at > UNIX_TIMESTAMP() - 7 * 86400;

-- refused and failed calls today, and failed authentication
SELECT FROM_UNIXTIME(created_at) AS at, integration_id, central_actor, action, outcome, detail
FROM cms_api_audit WHERE outcome <> 'allowed' AND created_at > UNIX_TIMESTAMP(CURDATE()) ORDER BY audit_id DESC;

-- which keys are used how often
SELECT integration_id, outcome, COUNT(*) FROM cms_api_audit GROUP BY integration_id, outcome;

From the command line, audit:list prints recent changes without their before and after data:

php vendor/bin/wbengine.php audit:list --limit=50 --resource=articles --actor=anna@example.com --config=src/Config/Settings.json --root=src

--limit is 1 to 500 (default 20), --actor matches the actor label exactly, or the local user id when it is numeric. The central Admin reads the same data over HTTP, see the next section.

Change feed over HTTP

GET /api/v2/changes returns the change log, newest first, without the before and after data. It needs HTTPS and an integration key with the scope audit:read. The central Admin shows the feed on the Activity page of a site, for site administrators only. A key without that scope must be replaced by a new key.

Query parameters: page and per_page (1 to 100, default 25); the exact-match filters resource, resource_id, action, source (v2, v1 or cli) and actor; since and until as epoch seconds. An invalid filter is answered with 400. With outcome=rejected the feed lists rejected and failed writes from cms_api_audit instead, with the error code in detail.

Every entry has change_id, created_at, resource, resource_id, action, source, actor, integration_id and local_user_id. Reading the feed is itself recorded in the audit table as changes.list.

Retention and pruning

Nothing is deleted automatically. The keys in Settings.json (both default to 365, allowed 30 to 3650, invalid values raise an error):

"audit": { "retentionDays": 365, "revisionRetentionDays": 365 }

audit.retentionDays applies to cms_api_audit and cms_api_changes, audit.revisionRetentionDays to cms_api_revisions. Run the pruning from cron:

27 3 * * * www-data /usr/bin/php /var/www/wbengine-skeleton-app/vendor/bin/wbengine.php audit:prune --config=/var/www/wbengine-skeleton-app/src/Config/Settings.json --root=/var/www/wbengine-skeleton-app/src >> /var/log/wbengine-cron.log 2>&1

audit:prune --dry-run only counts, --days=N overrides both keys for one run. The newest change of every resource stays however old (its id is part of the structure ETag and of recovery) and so does every revision referenced by a draft. Each real run records itself as a cli change of the resource audit. In Docker run the command with docker compose -f docker-compose.production.yml exec -T web php vendor/bin/wbengine.php audit:prune ..., from the host cron.

Preparing the change log tables

Sites that use API v2 need the change log columns source, local_user_id and detail and the created_at indexes. Run sh bin/update-database.sh DATABASE once, after a backup; it is safe to run again. vendor/bin/wbengine.php doctor reports a failed check until the columns exist; until then they are skipped and nothing else changes.

sh bin/update-database.sh wbengine_skeleton_app
docker compose -f docker-compose.production.yml exec -T web sh bin/update-database.sh --print \
  | docker compose -f docker-compose.production.yml exec -T db sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot wbengine_skeleton_app'

The first command is for a database on the host, the second for the production Compose stack.

Privacy notes

  • Actor labels, user rows and content snapshots can contain personal data such as e-mail addresses and names. Treat the audit tables as personal data: restrict database access, and include them in your records of processing.
  • Choose a retention that matches your policy, and note that database backups keep rows longer than the retention setting.
  • The IP address lives only in web server logs. Set their rotation and retention separately (logrotate, the Docker log options).
  • The audit is a trace of who did what, not of what visitors read: public page views are not recorded per visitor.