Analytics
in package
Records security events into per-day and per-hour aggregate counters and, when the detailed event log is enabled, into a dedicated database table.
See the module-6 and the analytics-dashboard design specs.
Table of Contents
Constants
- PRUNE_HOOK = 'creationell_captcha_prune_events'
- Cron hook that enforces the configured event-log retention.
- COUNTER_RETENTION_DAYS = 90
- Aggregate daily-counter retention, in days.
- HOURLY_RETENTION_HOURS = 48
- Aggregate hourly-counter retention, in hours.
- OPTION = 'creationell_captcha_analytics'
- The option holding the aggregate daily counters.
- OPTION_HOURLY = 'creationell_captcha_analytics_hourly'
- The option holding the aggregate hourly counters (24-hour view).
- TYPES = ['verified', 'failed', 'firewall', 'ratelimit', 'underattack', 'underattack_passed', 'challenge']
- The seven recognised event types.
- VERIFICATION_DATA_TYPES = ['verified', 'failed', 'underattack_passed']
- The event types whose request actually carries an ALTCHA payload, i.e.
Properties
- $daily_deltas : array<string, array<string, int>>
- Pending daily counter deltas: { bucket => { type => count } }. Flushed on `shutdown` so a request that triggers N events incurs at most one get_option/update_option round-trip per counter table, regardless of N.
- $flush_hooked : bool
- Whether the shutdown flush has already been hooked for this request.
- $hourly_deltas : array<string, array<string, int>>
- Pending hourly counter deltas: { bucket => { type => count } }.
- $table_exists_memo : bool|null
- Request-local memo for table_exists().
- $table_exists_memo_blog : int
- The blog the memo above was taken on, or -1 when it holds nothing.
Methods
- clear_events() : int
- Deletes every row from the event-log table.
- count_events() : int
- Counts the event-log rows matching the given filter.
- ensure_prune_schedule() : void
- Makes sure the retention sweep is on the cron schedule. Idempotent — registered on `init`, so a slot lost to a `wp cron event delete`, a partial DB restore or a plugin re-activation comes back by itself.
- ensure_table() : void
- Creates or updates the event-log table. Idempotent — safe to call repeatedly.
- event_columns() : array<int, string>
- The columns of the event-log table, in schema order.
- flush_pending_deltas() : void
- Flushes the accumulated daily and hourly counter deltas back to the options table.
- get_daily_counts() : array<string, array<string, int>>
- Returns the aggregate daily counters, keyed by date.
- get_hourly_counts() : array<string, array<string, int>>
- Returns the aggregate hourly counters, keyed by 'Y-m-d H' (UTC).
- prune_events() : int
- Deletes event-log rows older than the configured retention period.
- query_events() : array<int, array<string, string>>
- Returns event-log rows matching the given filter, newest first.
- record() : void
- Records one security event: bumps the daily and hourly counters and — when the detailed event log is enabled and the per-type toggle is on — writes a table row. Never fatals.
- run_scheduled_prune() : void
- Cron callback for self::PRUNE_HOOK — deletes rows past the retention window. Skips silently when the kill switch is set (W3-2, see ensure_prune_schedule()) or when the table does not exist.
- table_exists() : bool
- Whether the event-log table currently exists.
- apply_deltas() : void
- Merges the given { bucket => { type => count } } deltas into the option value and prunes buckets older than the retention window.
-
build_event_filter()
: array{0: string, 1: array
} - Builds the WHERE clause and bound parameters for an event-log filter.
- bump_counter() : void
- Accumulates today's counter delta in the request-local cache.
- bump_hourly_counter() : void
- Accumulates the current hour's counter delta in the request-local cache.
- current_blog_id() : int
- The current blog id, or 0 on a single site (B-M11).
- current_path() : string
- The current request path for the event log.
- ensure_event_type_index() : void
- Idempotently adds the composite (event_type, created_at) index to the events table. Dashboard queries that filter by event_type benefit from a covering composite, whereas the standalone created_at index alone forces a filesort over a typed slice.
- ensure_flush_hook() : void
- Idempotently registers the shutdown flush. Called the first time a delta is recorded in this request.
- log_row() : void
- Writes one row into the event-log table and occasionally prunes it.
- server_value() : string
- Reads a $_SERVER header value, sanitised and length-capped.
- table_name() : string
- The fully-qualified event-log table name.
- verification_data() : string
- The submitted ALTCHA solution payload for the current request, capped.
Constants
PRUNE_HOOK
Cron hook that enforces the configured event-log retention.
public
mixed
PRUNE_HOOK
= 'creationell_captcha_prune_events'
COUNTER_RETENTION_DAYS
Aggregate daily-counter retention, in days.
private
mixed
COUNTER_RETENTION_DAYS
= 90
HOURLY_RETENTION_HOURS
Aggregate hourly-counter retention, in hours.
private
mixed
HOURLY_RETENTION_HOURS
= 48
OPTION
The option holding the aggregate daily counters.
private
mixed
OPTION
= 'creationell_captcha_analytics'
OPTION_HOURLY
The option holding the aggregate hourly counters (24-hour view).
private
mixed
OPTION_HOURLY
= 'creationell_captcha_analytics_hourly'
TYPES
The seven recognised event types.
private
mixed
TYPES
= ['verified', 'failed', 'firewall', 'ratelimit', 'underattack', 'underattack_passed', 'challenge']
VERIFICATION_DATA_TYPES
The event types whose request actually carries an ALTCHA payload, i.e.
private
mixed
VERIFICATION_DATA_TYPES
= ['verified', 'failed', 'underattack_passed']
the only ones for which the verification_data column has diagnostic
value. See verification_data() for why the other types must not copy it.
Properties
$daily_deltas
Pending daily counter deltas: { bucket => { type => count } }. Flushed on `shutdown` so a request that triggers N events incurs at most one get_option/update_option round-trip per counter table, regardless of N.
private
static array<string, array<string, int>>
$daily_deltas
= []
$flush_hooked
Whether the shutdown flush has already been hooked for this request.
private
static bool
$flush_hooked
= false
$hourly_deltas
Pending hourly counter deltas: { bucket => { type => count } }.
private
static array<string, array<string, int>>
$hourly_deltas
= []
$table_exists_memo
Request-local memo for table_exists().
private
static bool|null
$table_exists_memo
= null
DS-4 put a table_exists() guard in front of every log_row() insert; that
guard sits on the hot path of a logging-enabled site, so the SHOW TABLES behind it must not run once per event. null means "not yet
checked in this request"; ensure_table() resets it to null so a table
created mid-request is picked up.
$table_exists_memo_blog
The blog the memo above was taken on, or -1 when it holds nothing.
private
static int
$table_exists_memo_blog
= -1
B-M11: table_name() reads $wpdb->prefix, and switch_to_blog() rewires
that — the memo did not follow, so an answer taken on site A would have
been reused for site B's differently named table. Nothing in the loaded
plugin switches sites today (uninstall.php does, but never loads this
class), so this is a latent trap rather than a live bug; the network
deactivation loop added in this release walks sites with the plugin
loaded, which is exactly the neighbourhood where it would go off.
Methods
clear_events()
Deletes every row from the event-log table.
public
clear_events() : int
Return values
int —Number of rows deleted.
count_events()
Counts the event-log rows matching the given filter.
public
count_events(array<string, mixed> $args) : int
Parameters
- $args : array<string, mixed>
-
Filter args (id, search, event_type, date_from, date_to).
Return values
intensure_prune_schedule()
Makes sure the retention sweep is on the cron schedule. Idempotent — registered on `init`, so a slot lost to a `wp cron event delete`, a partial DB restore or a plugin re-activation comes back by itself.
public
static ensure_prune_schedule() : void
DS-1: before this, analytics_log_retention was enforced only by the
1-in-20 opportunistic prune inside log_row() and by the CLI. Both need
ongoing writes — switch the event log off (or lose the traffic) and the
existing rows, IP/user-agent/referrer included, stayed forever even
though the setting promised a retention window. The sweep below runs on
its own schedule and deliberately does NOT look at analytics_event_log:
a switched-off log is precisely the case that needs it.
W3-2 — it DOES look at the kill switch, and both halves do. Deleting rows
is the only destructive background operation this plugin has, and an
operator who sets CREATIONELL_CAPTCHA_DISABLE during an incident is
usually doing it to freeze the state, not to keep a sweep running against
the evidence he is about to read. "Off" has to mean "deletes nothing
either"; record() has read the same switch since Modul 11c.
The slot itself is left alone rather than unscheduled: the switch is a wp-config constant and can come back at any request, and clearing the schedule here would make the kill switch quietly rewrite cron state.
N6 — the limit of that argument, stated openly because the setting does
not state it: it holds for the SHORT use (freeze the state during an
incident). A kill switch that stays set longer than the retention window
— a staging wp-config copied to production, "plugin functionally off"
instead of deactivation — means the window never elapses, and the rows
DS-1 was built for (IP, user agent, referrer, user ID of a log that is
already switched off) stay forever. The guard is kept; the state is no
longer silent: wp creacaptcha doctor reports it (check 22,
Doctor_Command::retention_sweep_check()), the help text of
analytics_log_retention names it, and wp creacaptcha log prune runs
regardless of the kill switch as the way out.
ensure_table()
Creates or updates the event-log table. Idempotent — safe to call repeatedly.
public
ensure_table() : void
event_columns()
The columns of the event-log table, in schema order.
public
static event_columns() : array<int, string>
E3c — why this exists as a method rather than being derived from a row:
wp creacaptcha log list --fields=quatsch used to build its whitelist
from array_keys( $rows[0] ), so on an EMPTY log the command returned
"Keine Ereignisse gefunden." and exit 0 without ever looking at the
--fields value. A typo was reported as a typo on a busy site and
silently accepted on a quiet one. A single source of truth that does not
depend on there being data closes that.
The list is checked against the CREATE TABLE statement in ensure_table() by tests/test-analytics-data-guards.php, so it cannot drift away from the schema unnoticed — a hand-kept copy of a schema is otherwise exactly the kind of assertion that ages badly.
Tags
Return values
array<int, string>flush_pending_deltas()
Flushes the accumulated daily and hourly counter deltas back to the options table.
public
static flush_pending_deltas() : void
DS-8 — known and accepted limitation, stated here so no caller assumes more than the code delivers: the read-modify-write below is NOT atomic. Two requests that flush at the same time can both read the same value and the later write wins, so the counters can under-count. Batching the whole request into a single round-trip per counter table narrows the window compared to one round-trip per event — it does not close it.
The consequence is confined to the dashboard's aggregate tiles: the counters are display-only statistics, no protection decision reads them, and the detailed event log (when enabled) is written per row and is unaffected. A lock or a per-row counter table would be the fix if these numbers ever had to be exact.
get_daily_counts()
Returns the aggregate daily counters, keyed by date.
public
get_daily_counts() : array<string, array<string, int>>
Return values
array<string, array<string, int>>get_hourly_counts()
Returns the aggregate hourly counters, keyed by 'Y-m-d H' (UTC).
public
get_hourly_counts() : array<string, array<string, int>>
Return values
array<string, array<string, int>>prune_events()
Deletes event-log rows older than the configured retention period.
public
prune_events() : int
Return values
int —Number of rows deleted.
query_events()
Returns event-log rows matching the given filter, newest first.
public
query_events(array<string, mixed> $args) : array<int, array<string, string>>
Parameters
- $args : array<string, mixed>
-
Filter args (id, before_id, search, event_type, date_from, date_to) plus
limit(1–1000) andoffset(>= 0). Passbefore_idinstead ofoffsetto page through a growing table without repeating rows.
Return values
array<int, array<string, string>>record()
Records one security event: bumps the daily and hourly counters and — when the detailed event log is enabled and the per-type toggle is on — writes a table row. Never fatals.
public
record(string $type[, array<string, mixed> $context = [] ]) : void
Parameters
- $type : string
-
One of the recognised event types.
- $context : array<string, mixed> = []
-
Optional caller-supplied context.
run_scheduled_prune()
Cron callback for self::PRUNE_HOOK — deletes rows past the retention window. Skips silently when the kill switch is set (W3-2, see ensure_prune_schedule()) or when the table does not exist.
public
static run_scheduled_prune() : void
table_exists()
Whether the event-log table currently exists.
public
table_exists() : bool
DS-5: the table name goes through esc_like() before it is bound as a
LIKE pattern. Without it the underscores in <prefix>creationell_captcha_events
stay single-character wildcards, so any table matching that shape — say
wp0creationell0captcha0events — can be returned by SHOW TABLES and
compared unequal to the real name, making this method answer false
while the table is right there. prepare() escapes the value for the
SQL string literal, it does not neutralise LIKE metacharacters.
Return values
boolapply_deltas()
Merges the given { bucket => { type => count } } deltas into the option value and prunes buckets older than the retention window.
private
static apply_deltas(string $option, array<string, array<string, int>> $deltas, int $retention, string $bucket_format, int $bucket_seconds) : void
Parameters
- $option : string
-
Option name to read+write.
- $deltas : array<string, array<string, int>>
-
Pending increments by bucket and type.
- $retention : int
-
Retention amount (days or hours).
- $bucket_format : string
-
gmdate() format for bucket keys.
- $bucket_seconds : int
-
Seconds per retention unit (86400 or 3600).
build_event_filter()
Builds the WHERE clause and bound parameters for an event-log filter.
private
build_event_filter(array<string, mixed> $args) : array{0: string, 1: array}
Parameters
- $args : array<string, mixed>
-
Filter args: id, before_id, search, event_type, date_from, date_to.
Return values
array{0: string, 1: arraySQL fragment ('' or ' WHERE …') and params.
bump_counter()
Accumulates today's counter delta in the request-local cache.
private
bump_counter(string $type) : void
Parameters
- $type : string
bump_hourly_counter()
Accumulates the current hour's counter delta in the request-local cache.
private
bump_hourly_counter(string $type) : void
Parameters
- $type : string
current_blog_id()
The current blog id, or 0 on a single site (B-M11).
private
static current_blog_id() : int
get_current_blog_id() is a WordPress core function and always exists at
runtime; the guard is here because the Ebene-2 suites load this class
without the WordPress bootstrap and must not have to stub a function to
keep a memo key correct.
Return values
intcurrent_path()
The current request path for the event log.
private
current_path() : string
Returns only the URI path component — the query string is dropped so tokens passed as GET parameters (magic-login links, API keys, …) do not leak into the persistent event log. Strips control characters (DB hygiene) and caps at 255 bytes. The dashboard escapes the value on output.
The path itself comes from creationell_captcha_request_path(), the same
root the interceptor, the inject pass, the rate limiter, the firewall and
the REST detection use (audit module 27, finding C1). It used to run
wp_parse_url( REQUEST_URI, PHP_URL_PATH ) here, which reads a request
target as if it were a URL: for //kontakt/ the first segment becomes
the HOST and the path collapses to /, for ///kontakt/ to ''.
That mattered more here than anywhere else. The one-character spelling
POST //kontakt was the bypass the C1 fix closed — and the event log is
the record an operator reads afterwards to find out whether somebody
tried it. With the old parser the attempt was logged under /, i.e.
under a path that was never requested, so the forensic trail pointed
away from the attack instead of at it.
Deliberately kept: the empty answer when the request carries no
REQUEST_URI at all (WP-Cron, WP-CLI, unit runs). The root would report
/ there, which reads like a real front-page request; '' says "no
request path", which is what actually happened.
Return values
stringensure_event_type_index()
Idempotently adds the composite (event_type, created_at) index to the events table. Dashboard queries that filter by event_type benefit from a covering composite, whereas the standalone created_at index alone forces a filesort over a typed slice.
private
ensure_event_type_index() : void
ensure_flush_hook()
Idempotently registers the shutdown flush. Called the first time a delta is recorded in this request.
private
ensure_flush_hook() : void
log_row()
Writes one row into the event-log table and occasionally prunes it.
private
log_row(string $type[, array<string, mixed> $context = [] ]) : void
Honours the optional IP-anonymisation and request-body-fingerprint toggles from Modul 11c.
DS-7 — scope of analytics_anonymize_ip, spelled out so the field list
below is not mistaken for an anonymised record: the toggle truncates the
ip column and nothing else. user_agent, referrer, user_id,
verification_data and request_body are stored as submitted, and
path keeps the request path (only its query string is dropped, see
current_path()) — a row can therefore still be attributable with the
toggle on. The event log as a whole is opt-in and bounded by
analytics_log_retention.
Parameters
- $type : string
-
The event type.
- $context : array<string, mixed> = []
-
Caller-supplied context.
server_value()
Reads a $_SERVER header value, sanitised and length-capped.
private
server_value(string $key, int $max) : string
Parameters
- $key : string
-
The $_SERVER key.
- $max : int
-
Maximum length.
Return values
stringtable_name()
The fully-qualified event-log table name.
private
table_name() : string
Return values
stringverification_data()
The submitted ALTCHA solution payload for the current request, capped.
private
verification_data(string $type) : string
DS-6: $_POST['altcha'] is anonymous, attacker-chosen input, and up to
2048 bytes of it used to be copied into EVERY logged row — including
firewall, ratelimit, underattack and challenge events. None of
those four ever verified the value: the request was blocked, throttled
or served an interstitial, or it merely fetched a new challenge. The
copied bytes were therefore unverified input with no relation to the
event, on exactly the high-volume anonymous paths — roughly 2 KB of log
growth per blocked request, chosen by the sender. The column is now
filled only for the event types that really are a verification attempt.
What this does NOT do: it does not bound the field for those remaining
types — a failed event still stores up to 2048 attacker-chosen bytes.
The bound there is the retention window (now scheduler-enforced, DS-1)
plus the fact that the whole event log is opt-in.
Parameters
- $type : string
-
The event type being logged.