Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
chromed
/
wp-content
/
plugins
/
wpforms-lite
/
src
/
Analytics
:
Process.php
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
<?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'] ) ); } }