File "Process.php"
Full Path: /home/buyiwexj/public_html/wp-content/plugins/wpforms-lite/src/Analytics/Process.php
File size: 26.72 KB
MIME-type: text/x-php
Charset: utf-8
<?php
namespace WPForms\Analytics;
use WP_Post;
/**
* Ingest snapshots from the frontend tracker.
*
* Handles two ingestion paths: the wpforms_analytics_snapshot AJAX action
* (tab_switch beacons from navigator.sendBeacon) and the wpforms_process_complete
* hook (submission snapshots piggybacked onto wpforms_submit).
*
* @since 2.0.0
*/
class Process {
/**
* Upper bound for SMALLINT UNSIGNED counter columns (focus/click/input/errors).
*
* Clamps a tampered client so it cannot overflow the snapshot_fields write.
*
* @since 2.0.0
*/
private const MAX_SMALLINT = 65535;
/**
* Upper bound for the INT(10) UNSIGNED duration_ms column.
*
* @since 2.0.0
*/
private const MAX_UNSIGNED_INT = 4294967295;
/**
* Maximum stored length of a session ID (matches the VARCHAR(64) column).
*
* @since 2.0.0
*/
private const SESSION_ID_MAX_LENGTH = 64;
/**
* Maximum stored length of a subfield key (matches the VARCHAR(64) column).
*
* @since 2.0.0
*/
private const SUBFIELD_KEY_MAX_LENGTH = 64;
/**
* Maximum accepted byte length of the raw snapshot payload.
*
* The endpoint is `nopriv`, so an anonymous client could otherwise POST an
* arbitrarily large body into the LONGTEXT payload column. 64 KB is generous
* for the largest realistic form snapshot while bounding the write. Filterable
* via `wpforms_analytics_process_max_payload_bytes`.
*
* @since 2.0.0
*/
private const MAX_PAYLOAD_BYTES = 65535;
/**
* Maximum number of snapshot writes allowed per form+IP within the throttle window.
*
* Filterable via `wpforms_analytics_process_register_snapshot_throttle_max`.
*
* @since 2.0.0
*/
private const SNAPSHOT_THROTTLE_MAX = 60;
/**
* Throttle window, in seconds, over which SNAPSHOT_THROTTLE_MAX applies.
*
* @since 2.0.0
*/
private const SNAPSHOT_THROTTLE_WINDOW = 60;
/**
* Start the engine.
*
* @since 2.0.0
*/
public function init(): void {
$this->hooks();
}
/**
* Register WordPress hooks.
*
* @since 2.0.0
*/
private function hooks(): void {
add_action( 'wp_ajax_wpforms_analytics_snapshot', [ $this, 'handle_snapshot_ajax' ] );
add_action( 'wp_ajax_nopriv_wpforms_analytics_snapshot', [ $this, 'handle_snapshot_ajax' ] );
add_action( 'wpforms_process_complete', [ $this, 'handle_submission' ], 10, 4 );
}
/**
* Handle tab_switch beacons sent via navigator.sendBeacon().
*
* @since 2.0.0
*/
public function handle_snapshot_ajax(): void {
if ( ! check_ajax_referer( 'wpforms', 'nonce', false ) ) {
wp_send_json_error();
}
// Skip site staff (Author/Editor/Admin) so their own activity never
// pollutes visitor analytics. Resolved fresh per request, so this holds
// even when the page was served from a full-page cache.
if ( ! Analytics::should_track_user() ) {
wp_send_json_error();
}
$input = $this->read_snapshot_input();
if ( ! $this->is_valid_snapshot_input( $input ) ) {
wp_send_json_error();
}
// Reject oversized payloads before any form lookup or DB write — the
// endpoint is anonymous (`nopriv`), so the body length is untrusted.
if ( ! $this->is_payload_within_limit( $input['payload_raw'] ) ) {
wp_send_json_error();
}
$form_data = $this->resolve_published_form_data( $input['form_id'] );
if ( $form_data === null ) {
wp_send_json_error();
}
$valid_ids = $this->get_tracked_field_ids( $form_data );
$parsed = $this->parse_inner_payload( $input['payload_raw'], $valid_ids );
if ( $parsed === null ) {
wp_send_json_error();
}
// Per-form throttle: cap snapshot writes from a single form+IP in a short
// window so an anonymous client cannot flood the table. Keyed on form_id (not
// session_id, which the client rotates freely and would bypass the cap) plus
// IP. Fails open on any cache error so legitimate tracking is never blocked.
if ( ! $this->register_snapshot_throttle( $input['form_id'] ) ) {
wp_send_json_error();
}
$snapshot_id = $this->save_snapshot(
$input['form_id'],
$input['session_id'],
1,
$input['form_visible'],
$input['payload_raw'],
$parsed['page'],
$parsed['field_rows']
);
if ( $snapshot_id === 0 ) {
wp_send_json_error();
}
wp_send_json_success();
}
/**
* Resolve the decoded form config for a published form.
*
* Fetches the form as a WP_Post to assert 'publish' status, then decodes its
* post_content into the config array. Returns null when the form is missing,
* unpublished, or its config cannot be decoded — an unreadable config can't be
* field-validated, so the caller rejects the snapshot rather than recording a
* fieldless view.
*
* @since 2.0.0
*
* @param int $form_id Form ID.
*
* @return array|null Decoded form config, or null when the form is unusable.
*/
private function resolve_published_form_data( int $form_id ): ?array {
$form = wpforms()->obj( 'form' )->get( $form_id, [ 'cap' => false ] );
if ( ! $form instanceof WP_Post || $form->post_status !== 'publish' ) {
return null;
}
$form_data = wpforms_decode( $form->post_content ?? '' );
// An unreadable or empty config can't be field-validated; treat it like a
// missing form so the caller rejects instead of recording a fieldless view.
if ( ! is_array( $form_data ) || $form_data === [] ) {
return null;
}
return $form_data;
}
/**
* Read and sanitize the snapshot beacon fields from $_POST.
*
* @since 2.0.0
*
* @return array Sanitized inputs: form_id, session_id, trigger, form_visible, payload_raw.
*/
private function read_snapshot_input(): array {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$post = $_POST;
$session_id = $this->clip_session_id( wp_unslash( $this->to_scalar_string( $post['session_id'] ?? '' ) ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$payload_raw = wp_unslash( $this->to_scalar_string( $post['payload'] ?? '' ) );
// Auto-detect base64: JSON payloads start with '{', base64 does not.
if ( $payload_raw !== '' && isset( $payload_raw[0] ) && $payload_raw[0] !== '{' ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$decoded = base64_decode( $payload_raw, true );
if ( $decoded !== false ) {
$payload_raw = $decoded;
}
}
// For the three integer inputs we can't simply cast and keep the value: (int) and absint()
// coerce non-empty arrays to 1, which would let shapes like form_id[]=x slip past validation.
// scalar_int()/scalar_absint() guard with is_scalar first; non-scalars resolve to values
// that fail downstream checks (form_id === 0, trigger !== 1, form_visible not in [0,1]).
return [
'form_id' => $this->scalar_absint( $post['form_id'] ?? null ),
'session_id' => $session_id,
'trigger' => $this->scalar_int( $post['trigger'] ?? null, 0 ),
'form_visible' => $this->scalar_int( $post['form_visible'] ?? null, -1 ),
'payload_raw' => $payload_raw,
];
}
/**
* Validate the sanitized snapshot input envelope (pre-payload).
*
* Payload shape is checked separately by parse_inner_payload().
*
* @since 2.0.0
*
* @param array $input Sanitized input from read_snapshot_input().
*
* @return bool True when form_id, session_id, trigger and form_visible are shaped correctly.
*/
private function is_valid_snapshot_input( array $input ): bool {
if ( $input['form_id'] === 0 || $input['session_id'] === '' ) {
return false;
}
return $input['trigger'] === 1 && in_array( $input['form_visible'], [ 0, 1 ], true );
}
/**
* Whether the raw payload is within the accepted byte limit.
*
* @since 2.0.0
*
* @param string $payload_raw Raw JSON payload string.
*
* @return bool True when the payload is small enough to accept.
*/
private function is_payload_within_limit( string $payload_raw ): bool {
/**
* Filters the maximum accepted byte length of a snapshot payload.
*
* @since 2.0.0
*
* @param int $max_bytes Maximum payload size in bytes.
*/
$max_bytes = (int) apply_filters( 'wpforms_analytics_process_max_payload_bytes', self::MAX_PAYLOAD_BYTES );
return strlen( $payload_raw ) <= $max_bytes;
}
/**
* Register a snapshot write against the per-form+IP throttle.
*
* Counts writes in a short rolling window using a transient. Returns false
* once the cap is exceeded so the caller can bail. Fails OPEN: if the cache
* layer cannot store the counter, the write is allowed rather than blocked,
* so a broken object cache never silently drops legitimate tracking.
*
* Keyed on form_id + IP rather than session_id: the client generates a fresh
* session_id per page-load and rotates it on flush, so a session-keyed cap is
* trivially bypassed. form_id + IP caps a flooding source while keeping
* separate buckets per form to limit shared-NAT false positives.
*
* @since 2.0.0
*
* @param int $form_id Form ID the snapshot belongs to.
*
* @return bool True when the write is allowed, false when the cap is exceeded.
*/
private function register_snapshot_throttle( int $form_id ): bool {
/**
* Filters the maximum number of snapshot writes per form+IP per window.
*
* @since 2.0.0
*
* @param int $max Maximum writes allowed in the throttle window.
*/
$max = (int) apply_filters( 'wpforms_analytics_process_register_snapshot_throttle_max', self::SNAPSHOT_THROTTLE_MAX );
// A non-positive cap disables throttling entirely.
if ( $max <= 0 ) {
return true;
}
$key = 'wpforms_an_thr_' . md5( $form_id . '|' . wpforms_get_ip() ); // NOSONAR.
$count = (int) get_transient( $key );
if ( $count >= $max ) {
return false;
}
// Re-set the window on every write — a steadily active session keeps the
// counter alive, which is the behaviour we want for flood protection.
set_transient( $key, $count + 1, self::SNAPSHOT_THROTTLE_WINDOW );
return true;
}
/**
* Parse and shape the inner JSON payload shared by both ingestion paths.
*
* The tab_switch beacon and the submission envelope both carry this same
* inner shape: { page: ?int, fields: [ ... ] }. Centralizing the decode,
* structure check, page coercion and field-row build prevents the two
* handlers from drifting.
*
* @since 2.0.0
*
* @param string $payload_raw Raw JSON string from either the AJAX payload or the submission envelope.
* @param array $valid_ids Field IDs eligible for tracking. Rows for other IDs are dropped.
*
* @return array|null [ 'page' => int|null, 'field_rows' => array ] on success, null when malformed.
*/
private function parse_inner_payload( string $payload_raw, array $valid_ids ): ?array {
$payload = json_decode( $payload_raw, true );
if ( ! is_array( $payload ) || ! isset( $payload['fields'] ) || ! is_array( $payload['fields'] ) ) {
return null;
}
// (int) not absint() so negatives stay negative and fail the > 0 gate;
// absint() would flip '-5' to 5 and record against the wrong 1-indexed page.
// Clamp the upper bound to the page_number column's TINYINT UNSIGNED max
// (255) so a garbage payload cannot exceed the range and make strict-mode
// MySQL reject the whole snapshot row.
$page_raw = $payload['page'] ?? null;
$page_int = is_numeric( $page_raw ) ? (int) $page_raw : 0;
$page = $page_int > 0 ? min( $page_int, 255 ) : null;
return [
'page' => $page,
'field_rows' => $this->build_field_rows( $payload['fields'], $valid_ids ),
];
}
/**
* Save a form-submission snapshot (trigger_type = 2).
*
* Reads the envelope from $_POST['wpforms']['analytics'], which the JS tracker
* appends to the existing wpforms_submit AJAX request. The envelope is JSON-encoded
* once (its payload is a nested object, not a pre-stringified string) so the form
* POST carries no backslash-escaped quotes for WAF anomaly rules to score. When the
* key is absent (CLI/REST/internal caller), we early-return without logging.
*
* @since 2.0.0
*
* @param array $fields Sanitized submission fields (unused here).
* @param array $entry Raw entry data (unused here).
* @param array $form_data Form config array.
* @param int $entry_id Entry ID (unused here).
*
* @noinspection PhpUnusedParameterInspection
*/
public function handle_submission( array $fields, array $entry, array $form_data, int $entry_id ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
// Skip site staff (Author/Editor/Admin) so their own submissions never
// pollute visitor analytics. This is the authoritative, cache-immune gate.
if ( ! Analytics::should_track_user() ) {
return;
}
$envelope = $this->extract_submission_envelope();
if ( $envelope === null ) {
return;
}
$identity = $this->resolve_submission_identity( $envelope, $form_data );
if ( $identity === null ) {
return;
}
// The submission envelope carries `payload` as a nested object so the form
// POST contains no backslash-escaped quotes for WAF anomaly rules to score.
// Re-encode it to the JSON string that parse_inner_payload() and the stored
// `payload` column both expect. A pre-stringified payload from an older cached
// analytics.js is still accepted as-is, so upgrades stay safe.
$payload_val = $envelope['payload'] ?? '';
$payload_raw = is_array( $payload_val )
? (string) wp_json_encode( $payload_val )
: $this->to_scalar_string( $payload_val );
$valid_ids = $this->get_tracked_field_ids( $form_data );
$parsed = $this->parse_inner_payload( $payload_raw, $valid_ids );
if ( $parsed === null ) {
return;
}
$form_visible = $this->normalize_submission_visible( $envelope['form_visible'] ?? 1 );
$this->save_snapshot(
$identity['form_id'],
$identity['session_id'],
2,
$form_visible,
$payload_raw,
$parsed['page'],
$parsed['field_rows']
);
}
/**
* Resolve and validate the form_id + session_id for a submission snapshot.
*
* The envelope was already unslashed when decoded in
* extract_submission_envelope(), so session_id is clipped here without a
* second wp_unslash(). Returns null when either value is empty.
*
* @since 2.0.0
*
* @param array $envelope Decoded submission envelope.
* @param array $form_data Form config array.
*
* @return array|null [ 'form_id' => int, 'session_id' => string ], or null when invalid.
*/
private function resolve_submission_identity( array $envelope, array $form_data ): ?array {
$form_id = absint( $form_data['id'] ?? 0 );
$session_id = $this->clip_session_id( $this->to_scalar_string( $envelope['session_id'] ?? '' ) );
if ( $form_id === 0 || $session_id === '' ) {
return null;
}
return [
'form_id' => $form_id,
'session_id' => $session_id,
];
}
/**
* Normalize the submission envelope's form_visible flag to 0 or 1.
*
* The is_scalar guard matters because a non-empty array would otherwise
* coerce to 1 via (int), silently flipping a malformed payload to "visible".
* When the envelope's form_visible isn't a scalar — or isn't one of 0/1 —
* fall back to 1 (submissions are always on a visible form by construction).
*
* @since 2.0.0
*
* @param mixed $raw Raw form_visible value from the decoded envelope.
*
* @return int Either 0 or 1.
*/
private function normalize_submission_visible( $raw ): int {
$form_visible = $this->scalar_int( $raw, 1 );
return in_array( $form_visible, [ 0, 1 ], true ) ? $form_visible : 1;
}
/**
* Extract and decode the analytics envelope piggy-backed on wpforms_submit.
*
* The upstream wpforms_submit handler has already validated the submission
* nonce before wpforms_process_complete fires, so we do not re-check it here.
*
* @since 2.0.0
*
* @return array|null Decoded envelope array, or null when missing/invalid.
*/
private function extract_submission_envelope(): ?array {
// We only check array shape here; is_array() means no unslash/sanitize needed yet.
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$wpforms_post = $_POST['wpforms'] ?? null;
if ( ! is_array( $wpforms_post ) ) {
return null;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$raw = $this->to_scalar_string( $wpforms_post['analytics'] ?? '' );
if ( $raw === '' ) {
return null;
}
$unslashed = wp_unslash( $raw );
// Auto-detect encoding: JSON starts with '{', base64 does not.
if ( isset( $unslashed[0] ) && $unslashed[0] !== '{' ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$unslashed = base64_decode( $unslashed, true );
if ( $unslashed === false ) {
return null;
}
}
$envelope = json_decode( $unslashed, true );
return is_array( $envelope ) ? $envelope : null;
}
/**
* Persist a snapshot header and, on Pro, its field rows.
*
* @since 2.0.0
*
* @param int $form_id Form ID.
* @param string $session_id Sanitized session ID.
* @param int $trigger_type Trigger type: 1 = tab_switch, 2 = form_submission.
* @param int $form_visible Visibility flag: 0 or 1.
* @param string $payload_raw Raw JSON payload string.
* @param int|null $page Page number or null.
* @param array $fields Normalized field rows.
*
* @return int Snapshot ID, or 0 on failure.
*/
private function save_snapshot(
int $form_id,
string $session_id,
int $trigger_type,
int $form_visible,
string $payload_raw,
?int $page,
array $fields
): int {
$db = wpforms()->obj( 'analytics_db' );
if ( ! $db ) {
return 0;
}
$snapshot_id = (int) $db->save(
[
'form_id' => $form_id,
'session_id' => $session_id,
'trigger_type' => $trigger_type,
'page_number' => $page,
'form_visible' => $form_visible,
'payload' => $payload_raw,
'occurred_at' => current_time( 'mysql' ),
'processed' => 0,
]
);
if ( $snapshot_id === 0 ) {
wpforms_log(
'Analytics snapshot write failed',
[
'form_id' => $form_id,
'trigger' => $trigger_type,
],
[
'type' => [ 'error' ],
'force' => true,
]
);
return 0;
}
if ( ! empty( $fields ) && method_exists( $db, 'save_fields' ) ) {
$db->save_fields( $snapshot_id, $fields );
}
return $snapshot_id;
}
/**
* Build normalized rows for analytics_snapshot_fields from the raw payload.
*
* Invalid entries (missing or non-numeric id) are skipped. Counts are
* clamped to column limits so a broken client cannot overflow writes.
*
* @since 2.0.0
*
* @param array $fields Raw fields array from the decoded payload.
* @param array $valid_ids Field IDs eligible for tracking. Rows for other IDs are dropped.
*
* @return array List of sanitized row arrays ready for DB::save_fields().
*/
private function build_field_rows( array $fields, array $valid_ids ): array {
$out = [];
foreach ( $fields as $field ) {
$row = $this->build_field_row( $field, $valid_ids );
if ( $row !== null ) {
$out[] = $row;
}
}
return $out;
}
/**
* Normalize a single raw field entry into a sanitized row.
*
* @since 2.0.0
*
* @param mixed $field Raw field entry from the decoded payload.
* @param array $valid_ids Field IDs eligible for tracking. Rows for other IDs are dropped.
*
* @return array|null Sanitized row, or null when the entry is invalid.
*/
private function build_field_row( $field, array $valid_ids ): ?array {
if ( ! is_array( $field ) ) {
return null;
}
if ( ! isset( $field['id'] ) || ! is_numeric( $field['id'] ) ) {
return null;
}
$id = absint( $field['id'] );
// Field ID 0 is valid — it is the first field added to a blank form
// (Form::next_field_id() returns 0 for it). $valid_ids, derived from
// form_data['fields'] keys, is the authority on which IDs to keep.
if ( ! in_array( $id, $valid_ids, true ) ) {
return null;
}
return $this->build_field_metrics( $id, $field );
}
/**
* Assemble the sanitized metric row for an already-validated field entry.
*
* Counts are clamped to their column limits so a broken client cannot
* overflow the write. duration_ms stays null when absent — null (never
* interacted) is semantically distinct from 0 (interacted, took 0 ms).
*
* @since 2.0.0
*
* @param int $id Validated field ID.
* @param array $field Raw field entry from the decoded payload.
*
* @return array Sanitized row ready for DB::save_fields().
*/
private function build_field_metrics( int $id, array $field ): array {
return [
'field_id' => $id,
'subfield_key' => $this->sanitize_subfield_key( $field['subKey'] ?? '' ),
'was_displayed' => empty( $field['displayed'] ) ? 0 : 1,
'focus_count' => $this->clamp_count( $field, 'focusCount', self::MAX_SMALLINT ),
'click_count' => $this->clamp_count( $field, 'clickCount', self::MAX_SMALLINT ),
'input_count' => $this->clamp_count( $field, 'inputCount', self::MAX_SMALLINT ),
'errors' => $this->clamp_count( $field, 'errors', self::MAX_SMALLINT ),
'duration_ms' => $this->clamp_nullable_duration( $field ),
];
}
/**
* Build the list of field IDs eligible for analytics tracking.
*
* By default, this is every field ID present in form_data['fields']. Field
* IDs outside this list are dropped before reaching analytics_snapshot_fields:
* - The AntiSpam v3 honeypot field (assigned an ID that is deliberately
* absent from form_data['fields'] — see AntiSpam::get_honeypot_field_id).
* - Any other phantom field IDs a tampered client might inject.
*
* @since 2.0.0
*
* @param array $form_data Form data array.
*
* @return array Sanitized list of int field IDs.
*/
private function get_tracked_field_ids( array $form_data ): array {
$valid_ids = isset( $form_data['fields'] ) && is_array( $form_data['fields'] )
? array_keys( $form_data['fields'] )
: [];
/**
* Filters the list of field IDs eligible for analytics tracking.
*
* Default is every field ID present in form_data['fields']. The
* AntiSpam v3 honeypot is naturally excluded because it picks an
* ID that is not in form_data['fields'].
*
* @since 2.0.0
*
* @param array $valid_ids Field IDs eligible for tracking.
* @param array $form_data Form data array.
*/
$valid_ids = (array) apply_filters( 'wpforms_analytics_process_get_tracked_field_ids', $valid_ids, $form_data );
return array_map( 'absint', $valid_ids );
}
/**
* Coerce a request value to a string, returning '' for non-scalar inputs.
*
* Raw request members ($_POST values and decoded envelope members) can
* arrive as arrays or objects. A direct (string) cast on those would
* emit an "Array to string conversion" warning that pollutes the JSON
* response body on the AJAX path. Returning '' for non-scalars feeds
* cleanly into the downstream validation (empty session_id / malformed
* JSON payload both trigger silent rejection).
*
* @since 2.0.0
*
* @param mixed $value Raw value from $_POST or a decoded JSON envelope.
*
* @return string The value cast to string, or '' when the input isn't scalar.
*/
private function to_scalar_string( $value ): string {
return is_scalar( $value ) ? (string) $value : '';
}
/**
* Coerce a request value to an int, returning a default for non-scalars.
*
* A direct (int) cast on a non-empty array would yield 1, silently letting
* shapes like form_visible[]=x slip past the downstream 0/1 validation.
* Guarding with is_scalar keeps malformed values mapped to a value that
* fails those checks (the supplied default).
*
* @since 2.0.0
*
* @param mixed $value Raw value from $_POST or a decoded JSON envelope.
* @param int $fallback Value returned when $value isn't scalar.
*
* @return int The value cast to int, or the fallback when not scalar.
*/
private function scalar_int( $value, int $fallback ): int {
return is_scalar( $value ) ? (int) $value : $fallback;
}
/**
* Coerce a request value to a non-negative int, 0 for non-scalars.
*
* This is the absint() variant of scalar_int(): the is_scalar guard prevents
* a non-empty array from coercing to 1 and slipping a shape like form_id[]=x
* past the form_id === 0 gate.
*
* @since 2.0.0
*
* @param mixed $value Raw value from $_POST or a decoded JSON envelope.
*
* @return int The value cast via absint(), or 0 when not scalar.
*/
private function scalar_absint( $value ): int {
return is_scalar( $value ) ? absint( $value ) : 0;
}
/**
* Sanitize and clip a session ID to its stored column length.
*
* Callers pass an already string-coerced value; unslashing (where needed)
* happens at the call site because the two ingestion paths differ — the
* AJAX path unslashes raw $_POST, the submission path receives an envelope
* already unslashed at decode time.
*
* @since 2.0.0
*
* @param string $value String-coerced raw session ID.
*
* @return string Sanitized session ID, at most SESSION_ID_MAX_LENGTH chars.
*/
private function clip_session_id( string $value ): string {
return substr( sanitize_text_field( $value ), 0, self::SESSION_ID_MAX_LENGTH );
}
/**
* Sanitize a subfield key to the analytics column contract.
*
* Keys come from the input name bracket segment (e.g. 'first', 'postal',
* a Likert row slug). sanitize_key() bounds the charset; the substr bounds
* it to the VARCHAR(64) column so a tampered client cannot overflow.
*
* @since 2.0.0
*
* @param mixed $value Raw subKey from the decoded payload.
*
* @return string Sanitized subfield key, or '' when absent/invalid.
*/
private function sanitize_subfield_key( $value ): string {
return substr( sanitize_key( $this->to_scalar_string( $value ) ), 0, self::SUBFIELD_KEY_MAX_LENGTH );
}
/**
* Read a field counter by key and clamp it to its column's upper bound.
*
* A missing key reads as 0. Counts are clamped so a broken client cannot
* overflow the unsigned column.
*
* @since 2.0.0
*
* @param array $field Raw field entry from the decoded payload.
* @param string $key Counter key to read (e.g. 'focusCount').
* @param int $max Inclusive upper bound (column limit).
*
* @return int Non-negative count, never above $max.
*/
private function clamp_count( array $field, string $key, int $max ): int {
return min( $max, absint( $field[ $key ] ?? 0 ) );
}
/**
* Read and clamp the nullable durationMs value from a field entry.
*
* Returns null when absent — null (never interacted) is semantically
* distinct from 0 (interacted, took 0 ms).
*
* @since 2.0.0
*
* @param array $field Raw field entry from the decoded payload.
*
* @return int|null Clamped duration in ms, or null when not present.
*/
private function clamp_nullable_duration( array $field ): ?int {
if ( ! isset( $field['durationMs'] ) ) {
return null;
}
return min( self::MAX_UNSIGNED_INT, absint( $field['durationMs'] ) );
}
}