CreaCaptcha

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
int

ensure_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
since
1.1.0
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) and offset (>= 0). Pass before_id instead of offset to 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
bool

apply_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: array} —

SQL 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
int

current_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
string

ensure_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
string

table_name()

The fully-qualified event-log table name.

private table_name() : string
Return values
string

verification_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.

Return values
string

        
On this page

Search results