$table ) { $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); $rows[] = array( 'table' => $name, 'exists' => $exists ? 'yes' : 'NO', 'rows' => $exists ? (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ) : '-', ); } \WP_CLI\Utils\format_items( 'table', $rows, array( 'table', 'exists', 'rows' ) ); foreach ( Saddle_Analytics_Install::tables() as $name => $table ) { $indexes = $wpdb->get_col( $wpdb->prepare( 'SHOW INDEX FROM %i', $table ), 2 ); WP_CLI::log( "indexes[{$name}]: " . ( $indexes ? implode( ', ', array_unique( $indexes ) ) : '(unavailable)' ) ); } $next = wp_next_scheduled( Saddle_Analytics_Cron::HOOK ); WP_CLI::log( 'cron: ' . ( $next ? 'scheduled (next ' . gmdate( 'Y-m-d H:i:s', $next ) . ' UTC)' : 'NOT SCHEDULED' ) ); WP_CLI::log( 'version: ' . get_option( 'saddle_analytics_version', '(none)' ) ); WP_CLI::log( 'identity: ' . ( Saddle_Analytics_Identity::cookie_mode() ? 'cookie (remember_visitors on)' : 'cookieless (daily salted hash)' ) ); } /** * The saddle-analytics/* abilities an agent sees (through Saddle: saddle-analytics-*), * or run one exactly as an agent would — input validated, permission checked, * output validated. Pass --user= so the permission check passes. * [--run=] [--input=] */ public function abilities( array $args, array $assoc ): void { if ( ! function_exists( 'wp_get_abilities' ) ) { WP_CLI::error( 'The Abilities API needs WordPress 6.9 or later.' ); } if ( isset( $assoc['run'] ) ) { $ability = wp_get_ability( (string) $assoc['run'] ); if ( ! $ability ) { WP_CLI::error( 'No such ability: ' . $assoc['run'] ); } $input = isset( $assoc['input'] ) ? json_decode( (string) $assoc['input'], true ) : array(); $result = $ability->execute( is_array( $input ) ? $input : array() ); if ( is_wp_error( $result ) ) { WP_CLI::error( $result->get_error_code() . ': ' . $result->get_error_message() ); } WP_CLI::log( (string) wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); return; } $rows = array(); foreach ( wp_get_abilities() as $ability ) { $name = $ability->get_name(); if ( 0 === strpos( $name, Saddle_Analytics_Abilities::CATEGORY . '/' ) || 0 === strpos( $name, 'saddle/' . Saddle_Analytics_Abilities::SLUG . '-' ) ) { $meta = $ability->get_meta(); $rows[] = array( 'ability' => $name, 'readonly' => ! empty( $meta['annotations']['readonly'] ) ? 'yes' : 'no', 'label' => $ability->get_label(), ); } } \WP_CLI\Utils\format_items( 'table', $rows, array( 'ability', 'readonly', 'label' ) ); } /** * The cookieless visitor id an IP + user agent hashes to today — the same * computation the beacon runs. Never prints the salt. * [--ip=] [--ua=] */ public function identity( array $args, array $assoc ): void { $ip = (string) ( $assoc['ip'] ?? '127.0.0.1' ); $ua = (string) ( $assoc['ua'] ?? self::UA_DESKTOP ); $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $day = gmdate( 'Y-m-d' ); $id = Saddle_Analytics_Identity::hash( $ip, $ua, $host, Saddle_Analytics_Identity::salt( $day ) ); $last = Saddle_Analytics_Identity::latest_hit( $id ); self::kv( 'identity', array( 'mode' => Saddle_Analytics_Identity::cookie_mode() ? 'cookie' : 'cookieless', 'day (UTC)' => $day, 'visitor_id' => $id, 'latest hit' => $last ? $last['ts'] . ' (session ' . $last['session_id'] . ')' : '(none today)', 'salt days' => implode( ', ', array_keys( (array) get_option( Saddle_Analytics_Identity::SALTS, array() ) ) ), ) ); } /** * Run the real pipeline + save directly (no HTTP), print hit + visitor row. * (--page, not --path: --path is WP-CLI's own global flag.) * * [--page=

] [--referrer=] [--ua=] [--visitor=] [--session=] * [--utm_source=] [--utm_medium=] [--utm_campaign=] * [--screen=] [--country=] [--ts=] * [--ip=] cookieless sites: hash this IP exactly as the beacon would */ public function hit( array $args, array $assoc ): void { $hit = self::run_hit( $assoc ); if ( $hit->dropped() ) { // Same side effect the real beacon has (Phase 18): a bot drop counts // in the crawler rollup, so `hit --ua=GPTBot` is the seeding path. Saddle_Analytics::crawlers()->record_drop( (string) $hit->drop_reason ); WP_CLI::warning( 'dropped: ' . $hit->drop_reason ); return; } self::kv( 'hit', get_object_vars( $hit ) ); global $wpdb; $t = Saddle_Analytics_Install::tables(); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE visitor_id = %s', $t['visitors'], $hit->visitor_id ), ARRAY_A ); self::kv( 'visitor', (array) $row ); } /** Deterministic fake data. [--visitors=] [--days=] [--seed=] */ public function seed( array $args, array $assoc ): void { $visitors = (int) ( $assoc['visitors'] ?? 500 ); $days = max( 1, (int) ( $assoc['days'] ?? 30 ) ); // A seeded LCG, not $rng(): same numbers for the same --seed on every PHP, // and no global RNG state touched. $rng = self::rng( (int) ( $assoc['seed'] ?? 42 ) ); // channel => [weight, referrer]. $mix = array( array( 34, '' ), array( 30, 'https://www.google.com/' ), array( 12, 'https://www.facebook.com/' ), array( 8, 'https://chatgpt.com/' ), array( 7, 'https://duckduckgo.com/?utm_medium=cpc&x' ), array( 5, 'https://statamic.example.com/' ), array( 4, '' ), // email utm. ); $paths = array( '/', '/pricing', '/blog/attribution', '/features', '/docs', '/about' ); $geo = array( 'US', 'GB', 'DE', 'BD', 'FR', 'CA', 'AU', 'NL' ); for ( $i = 0; $i < $visitors; $i++ ) { $pick = self::weighted( $mix, $rng ); $utm = 4 === $pick ? array( 'utm_medium' => 'cpc', 'utm_source' => 'google' ) : ( 6 === $pick ? array( 'utm_medium' => 'email', 'utm_source' => 'newsletter', 'utm_campaign' => 'july-promo' ) : array() ); $ref = $mix[ $pick ][1]; $visitor_id = null; $sessions = $rng( 1, 3 ); $day = $rng( 0, $days - 1 ); for ( $s = 0; $s < $sessions; $s++ ) { $epoch = time() - $day * DAY_IN_SECONDS - $rng( 0, 60000 ); $views = $rng( 1, 4 ); $sess = null; for ( $v = 0; $v < $views; $v++ ) { // Later pageviews land 20-180s apart so visit duration is non-zero. $ts = gmdate( 'Y-m-d H:i:s', $epoch + ( $v > 0 ? $v * $rng( 20, 180 ) : 0 ) ); $hit = self::run_hit( array( 'path' => $paths[ $rng( 0, count( $paths ) - 1 ) ], 'referrer' => ( 0 === $v && 0 === $s ) ? $ref : '', 'visitor' => $visitor_id, 'session' => $sess, 'screen' => $rng( 0, 2 ) ? 1440 : 390, 'country' => $geo[ $rng( 0, count( $geo ) - 1 ) ], 'ts' => $ts, ) + ( 0 === $s ? $utm : array() ) ); $visitor_id = $hit->visitor_id; $sess = $hit->session_id; } if ( 0 === $rng( 0, 5 ) ) { $targets = array( 'https://github.com/example', 'https://twitter.com/example', 'https://docs.partner.io/setup' ); self::run_hit( array( 'path' => '/docs', 'target' => $targets[ $rng( 0, 2 ) ], 'visitor' => $visitor_id, 'session' => $sess, 'ts' => $ts, ) ); } $day = max( 0, $day - $rng( 0, 5 ) ); } } WP_CLI::success( "Seeded {$visitors} visitors over {$days} days." ); } /** Print a dimension table. [--metric=] [--range=] [--from=] [--to=] [--filter=] */ public function report( array $args, array $assoc ): void { $key = $args[0] ?? 'channel'; $dimension = Saddle_Analytics_Dimensions::get( $key ); if ( ! $dimension ) { WP_CLI::error( "Unknown dimension '{$key}'. Valid: " . implode( ', ', Saddle_Analytics_Dimensions::keys() ) ); } list( $from, $to ) = self::window( $assoc ); $filters = array(); if ( ! empty( $assoc['filter'] ) && str_contains( (string) $assoc['filter'], '=' ) ) { list( $fk, $fv ) = explode( '=', (string) $assoc['filter'], 2 ); $filters[ $fk ] = $fv; } $rows = Saddle_Analytics::report()->table( $dimension, (string) ( $assoc['metric'] ?? 'visitors' ), (string) ( $assoc['range'] ?? '30d' ), $filters, 100, $from, $to ); if ( ! $rows ) { WP_CLI::log( 'No rows.' ); return; } foreach ( $rows as &$row ) { $row['value'] = self::format_value( $row['value'], $dimension->format ); } \WP_CLI\Utils\format_items( 'table', $rows, array( 'value', 'visitors', 'pageviews' ) ); } /** The WP-dashboard widget numbers: last-30-minute actives, per-minute counts, top countries. */ public function realtime(): void { $data = Saddle_Analytics::realtime()->data(); WP_CLI::log( 'online (30m): ' . $data['online'] ); foreach ( $data['minutes'] as $minute ) { if ( $minute['visitors'] > 0 ) { WP_CLI::log( gmdate( 'H:i', $minute['ts'] ) . ' ' . str_repeat( '#', min( 60, $minute['visitors'] ) ) . ' ' . $minute['visitors'] ); } } foreach ( $data['countries'] as $row ) { WP_CLI::log( 'country ' . $row['country'] . ': ' . $row['visitors'] ); } } /** The AI-crawler report: bots by purpose over a window. [--range=] [--from=] [--to=] */ public function crawlers( array $args, array $assoc ): void { list( $from, $to ) = self::window( $assoc ); $data = Saddle_Analytics::crawlers()->crawlers( (string) ( $assoc['range'] ?? '30d' ), $from, $to ); if ( ! $data['rows'] ) { WP_CLI::log( 'No crawler hits recorded in this window yet.' ); WP_CLI::log( 'Note: crawler visits are counted when WordPress serves the page. A full-page cache can hide them from any plugin.' ); // The same watchlist the panel's empty state shows (rule 10 parity). $groups = array(); foreach ( $data['watching'] ?? array() as $w ) { $groups[ (int) $w['purpose'] ][] = $w['bot']; } ksort( $groups ); foreach ( $groups as $purpose => $bots ) { WP_CLI::log( 'Watching (' . Saddle_Analytics_Purpose::label( $purpose ) . '): ' . implode( ', ', $bots ) ); } return; } $rows = array_map( static fn( array $r ) => array( 'bot' => $r['bot'], 'purpose' => Saddle_Analytics_Purpose::label( $r['purpose'] ), 'hits' => $r['hits'], ), $data['rows'] ); \WP_CLI\Utils\format_items( 'table', $rows, array( 'bot', 'purpose', 'hits' ) ); foreach ( $data['purposes'] as $purpose => $hits ) { WP_CLI::log( Saddle_Analytics_Purpose::label( (int) $purpose ) . ': ' . $hits ); } } /** The chart's buckets: hourly for --range=today, daily otherwise. [--range=] [--compare] */ public function timeseries( array $args, array $assoc ): void { list( $from, $to ) = self::window( $assoc ); $compare = isset( $assoc['compare'] ); $data = Saddle_Analytics::report()->timeseries( (string) ( $assoc['range'] ?? '30d' ), $from, $to, $compare ); $hourly = 'hour' === $data['interval']; $peak = max( 1, max( array_column( $data['points'], 'visitors' ) ?: array( 1 ) ) ); $before = $data['previous'] ?? null; WP_CLI::log( 'interval: ' . $data['interval'] . ' (times UTC)' ); foreach ( $data['points'] as $i => $point ) { WP_CLI::log( sprintf( '%-7s %-40s %6d%s', gmdate( $hourly ? 'H:i' : 'd M', $point['ts'] ), str_repeat( '#', (int) round( 40 * $point['visitors'] / $peak ) ), $point['visitors'], null === $before ? '' : sprintf( ' prev %6d', $before[ $i ]['visitors'] ?? 0 ) ) ); } // Visitors do not sum to `summary`: each bucket counts its own DISTINCT // visitors, so a returner counts once per bucket they appear in. WP_CLI::log( sprintf( 'total: %d bucket-visitors across %d buckets', array_sum( array_column( $data['points'], 'visitors' ) ), count( $data['points'] ) ) ); } /** The five cards. [--range=] [--from=] [--to=] [--compare] */ public function summary( array $args, array $assoc ): void { list( $from, $to ) = self::window( $assoc ); $compare = isset( $assoc['compare'] ); $cards = Saddle_Analytics::report()->summary( (string) ( $assoc['range'] ?? '30d' ), $from, $to, $compare ); $columns = array( 'window', 'visitors', 'visits', 'pageviews', 'bounce rate', 'duration', 'online now' ); $rows = array( array( 'window' => 'current' ) + self::summary_row( $cards ) ); if ( $compare && empty( $cards['previous'] ) ) { WP_CLI::warning( 'Lifetime has no preceding window — nothing to compare against.' ); } elseif ( $compare ) { $rows[] = array( 'window' => 'previous' ) + self::summary_row( $cards['previous'] ); $rows[] = array( 'window' => 'delta' ) + self::delta_row( $cards['delta'] ); } if ( ! $compare ) { // Without --compare the shape is exactly what it was before Phase 13. array_shift( $columns ); unset( $rows[0]['window'] ); } \WP_CLI\Utils\format_items( 'table', $rows, $columns ); } /** One window's cards, formatted for the table. */ private static function summary_row( array $c ): array { return array( 'visitors' => $c['visitors'], 'visits' => $c['visits'], 'pageviews' => $c['pageviews'], 'bounce rate' => round( $c['bounce_rate'] * 100 ) . '%', 'duration' => sprintf( '%dm %02ds', intdiv( $c['duration'], 60 ), $c['duration'] % 60 ), 'online now' => $c['online'], ); } /** The delta row: percentages, or '—' where there was no previous value. */ private static function delta_row( array $d ): array { $pct = static function ( ?float $v ): string { if ( null === $v ) { return '—'; } return sprintf( '%+.1f%%', $v * 100 ); }; return array( 'visitors' => $pct( $d['visitors'] ), 'visits' => $pct( $d['visits'] ), 'pageviews' => $pct( $d['pageviews'] ), 'bounce rate' => $pct( $d['bounce_rate'] ), 'duration' => $pct( $d['duration'] ), 'online now' => '—', ); } /** * --from/--to, validated the same way the REST args are (rule 11) and with * the same wording, so the CLI and the dashboard fail identically. * * @return array{0:?string, 1:?string} */ private static function window( array $assoc ): array { $from = isset( $assoc['from'] ) ? (string) $assoc['from'] : null; $to = isset( $assoc['to'] ) ? (string) $assoc['to'] : null; if ( null === $from && null === $to ) { return array( null, null ); } foreach ( array( 'from' => $from, 'to' => $to ) as $flag => $value ) { if ( null !== $value && ! Saddle_Analytics_Window::valid_date( $value ) ) { WP_CLI::error( "--{$flag}={$value} is not a date. Use YYYY-MM-DD." ); } } if ( null === $from ) { WP_CLI::error( '--to needs a --from. Pass both dates or neither.' ); } if ( null !== $to && $to < $from ) { WP_CLI::error( '--from must be on or before --to.' ); } $today = gmdate( 'Y-m-d' ); if ( $from > $today || ( null !== $to && $to > $today ) ) { WP_CLI::error( 'Dates can\'t be in the future.' ); } return array( $from, $to ); } /** * Show or change settings. Bare: table of current values. * [--set=] e.g. --set=attribution=last, --set=track_admins=true */ public function settings( array $args, array $assoc ): void { if ( isset( $assoc['set'] ) ) { $pair = explode( '=', (string) $assoc['set'], 2 ); if ( 2 !== count( $pair ) || ! Saddle_Analytics_Settings::valid_key( $pair[0] ) ) { WP_CLI::error( 'Usage: --set=. Keys: track_admins, attribution, remember_visitors, retention_days, delete_data_on_uninstall.' ); } list( $key, $value ) = $pair; if ( 'attribution' === $key ) { if ( ! in_array( $value, array( 'first', 'last' ), true ) ) { WP_CLI::error( "attribution must be 'first' or 'last'." ); } $parsed = $value; } elseif ( 'retention_days' === $key ) { if ( ! ctype_digit( $value ) ) { WP_CLI::error( 'retention_days takes a whole number of days (' . Saddle_Analytics_Settings::RETENTION_MIN . '–' . Saddle_Analytics_Settings::RETENTION_MAX . ').' ); } $parsed = (int) $value; } elseif ( in_array( $key, array( 'track_admins', 'remember_visitors', 'delete_data_on_uninstall' ), true ) ) { $parsed = in_array( strtolower( $value ), array( '1', 'true', 'yes', 'on' ), true ); } else { $parsed = $value; } Saddle_Analytics_Settings::update( array( $key => $parsed ) ); WP_CLI::success( "{$key} updated." ); } $all = Saddle_Analytics_Settings::all(); \WP_CLI\Utils\format_items( 'table', array_map( static fn( $k, $v ) => array( 'setting' => $k, 'value' => is_bool( $v ) ? ( $v ? 'true' : 'false' ) : (string) $v ), array_keys( $all ), $all ), array( 'setting', 'value' ) ); } /** Visitor row + their hits. */ public function inspect( array $args ): void { $id = (string) ( $args[0] ?? '' ); if ( ! Saddle_Analytics_Step_Visitor::valid_id( $id ) ) { WP_CLI::error( 'Pass a 32-char visitor id.' ); } // Same queries the Journeys screen reads — one implementation, so the CLI // stays the oracle for the REST payload. $journey = Saddle_Analytics::journey(); $row = $journey->visitor( $id ); if ( ! $row ) { WP_CLI::error( 'No such visitor.' ); } self::kv( 'visitor', $row ); $hits = $journey->hits( $id ); WP_CLI::log( "\nhits (" . count( $hits ) . '):' ); $hits && \WP_CLI\Utils\format_items( 'table', $hits, array_keys( $hits[0] ) ); } /** * The visitor list behind the dashboard panel: every visitor, newest * activity first. [--limit=] [--offset=] */ public function journeys( array $args, array $assoc ): void { $data = Saddle_Analytics::journey()->all( (int) ( $assoc['limit'] ?? 20 ), (int) ( $assoc['offset'] ?? 0 ) ); if ( ! $data['rows'] ) { WP_CLI::log( 'No visitors yet.' ); return; } $rows = array_map( static fn( array $r ) => array( 'visitor' => $r['visitor_id'], 'first touch' => Saddle_Analytics_Channel::label( $r['first_channel'] ) . ( $r['first_referrer'] ? ' (' . $r['first_referrer'] . ')' : '' ), 'country' => $r['country'] ?? '—', 'client' => Saddle_Analytics_Device::label( isset( $r['device'] ) ? (int) $r['device'] : null ) . ' · ' . ( $r['browser'] ?? '—' ) . ' · ' . ( $r['os'] ?? '—' ), 'sessions' => $r['sessions'], 'last seen' => $r['last_seen'], ), $data['rows'] ); \WP_CLI\Utils\format_items( 'table', $rows, array( 'visitor', 'first touch', 'country', 'client', 'sessions', 'last seen' ) ); WP_CLI::log( sprintf( 'showing %d of %d visitors', count( $rows ), $data['total'] ) ); } /** * Recompute visitor running totals from the rows they summarise. [--dry-run] * * For sites that ran on SQLite before Phase 16, where MySQL's IF() silently * took its ELSE branch and left `sessions` frozen. * * What it can and cannot repair: * - sessions / pageviews are lifetime totals but hits prune at 90 days, so a * recount from surviving hits would DESTROY history. They are only ever * raised, never lowered: MAX(stored, counted). * - last_channel / last_referrer / last_campaign are NOT repairable. They * were never written, so there is nothing to recover — the command reports * how many visitors are affected and says so plainly. */ // Table names below come from Saddle_Analytics_Install::tables() ($wpdb->prefix), never input. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared public function repair( array $args, array $assoc ): void { global $wpdb; $dry = isset( $assoc['dry-run'] ); $t = Saddle_Analytics_Install::tables(); $fixed = 0; $scanned = 0; $raised = array( 'sessions' => 0, 'pageviews' => 0 ); $offset = 0; do { // A maintenance command, not the write path — SELECTs are fine here // (rule 2 governs track → pipeline → save). $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT v.visitor_id, v.sessions, v.pageviews, (SELECT COUNT(DISTINCT h.session_id) FROM %i h WHERE h.visitor_id = v.visitor_id) AS real_sessions, (SELECT COUNT(*) FROM %i h WHERE h.visitor_id = v.visitor_id AND h.event_type = 1) AS real_pageviews FROM %i v ORDER BY v.visitor_id LIMIT %d OFFSET %d', $t['hits'], $t['hits'], $t['visitors'], 500, $offset ), ARRAY_A ); $offset += 500; $scanned += count( $rows ); foreach ( $rows as $r ) { // Never lower a counter: pruned hits are gone, the total is not. $sessions = max( (int) $r['sessions'], (int) $r['real_sessions'] ); $pageviews = max( (int) $r['pageviews'], (int) $r['real_pageviews'] ); if ( $sessions === (int) $r['sessions'] && $pageviews === (int) $r['pageviews'] ) { continue; } ++$fixed; $sessions > (int) $r['sessions'] && ++$raised['sessions']; $pageviews > (int) $r['pageviews'] && ++$raised['pageviews']; if ( ! $dry ) { $wpdb->query( $wpdb->prepare( 'UPDATE %i SET sessions = %d, pageviews = %d WHERE visitor_id = %s', $t['visitors'], $sessions, $pageviews, $r['visitor_id'] ) ); } } } while ( count( $rows ) === 500 ); // last_* is gone, not wrong — be honest about it rather than guessing. $stranded = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i v WHERE v.last_channel = v.first_channel AND (SELECT COUNT(DISTINCT h.session_id) FROM %i h WHERE h.visitor_id = v.visitor_id) > 1', $t['visitors'], $t['hits'] ) ); WP_CLI::log( "scanned {$scanned} visitors" ); WP_CLI::log( sprintf( ' sessions raised: %d', $raised['sessions'] ) ); WP_CLI::log( sprintf( ' pageviews raised: %d', $raised['pageviews'] ) ); if ( $stranded > 0 ) { WP_CLI::warning( "{$stranded} visitors have more than one session but last_* still equal to first_*. Their last-touch data was never written and CANNOT be recovered — only visits from now on will record it." ); } WP_CLI::success( $dry ? "{$fixed} visitors would be repaired (dry run — nothing written)." : "Repaired {$fixed} visitors." ); } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared public function prune( array $args, array $assoc ): void { $dry = isset( $assoc['dry-run'] ); $n = Saddle_Analytics_Cron::prune( $dry ); WP_CLI::success( $dry ? "{$n} hits older than " . Saddle_Analytics_Cron::retention_days() . 'd would be deleted.' : "Deleted {$n} old hits." ); } /** Truncate all three tables. --yes */ public function reset( array $args, array $assoc ): void { if ( ! isset( $assoc['yes'] ) ) { WP_CLI::error( 'Add --yes to confirm wiping all Saddle Analytics data.' ); } global $wpdb; foreach ( Saddle_Analytics_Install::tables() as $table ) { $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table ) ); } WP_CLI::success( 'All Saddle Analytics tables truncated.' ); } private static function run_hit( array $a ): Saddle_Analytics_Hit { $p = new Saddle_Analytics_Payload(); $p->path = Saddle_Analytics_Track::clean_path( $a['page'] ?? $a['path'] ?? '/' ); $p->referrer = (string) ( $a['referrer'] ?? '' ); $p->utm = array( 'source' => (string) ( $a['utm_source'] ?? '' ), 'medium' => (string) ( $a['utm_medium'] ?? '' ), 'campaign' => (string) ( $a['utm_campaign'] ?? '' ), ); $p->ua = (string) ( $a['ua'] ?? self::UA_DESKTOP ); $p->screen_width = (int) ( $a['screen'] ?? 1440 ) ?: null; $p->cookie_visitor = $a['visitor'] ?? null; $p->cookie_session = $a['session'] ?? null; $p->country = $a['country'] ?? null; if ( ! empty( $a['target'] ) ) { $p->event_type = Saddle_Analytics_Event::EXIT_LINK; $p->target = (string) $a['target']; } $p->ts = (string) ( $a['ts'] ?? gmdate( 'Y-m-d H:i:s' ) ); $p->home_host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); if ( isset( $a['ip'] ) ) { // --ip runs the beacon's identity step: cookieless sites hash it, cookie sites ignore it. Saddle_Analytics_Identity::apply( $p, (string) $a['ip'] ); } $hit = Saddle_Analytics::pipeline()->run( $p ); if ( ! $hit->dropped() ) { Saddle_Analytics::save()->hit( $hit ); } return $hit; } /** A deterministic integer generator for seed(): $rng( $min, $max ), inclusive. */ private static function rng( int $seed ): callable { $state = ( $seed & 0x7fffffff ) ?: 1; return static function ( int $min, int $max ) use ( &$state ): int { $state = ( $state * 1103515245 + 12345 ) & 0x7fffffff; return $min + intdiv( $state, 7 ) % ( $max - $min + 1 ); }; } private static function weighted( array $mix, callable $rng ): int { $roll = $rng( 1, array_sum( array_column( $mix, 0 ) ) ); foreach ( $mix as $i => $row ) { $roll -= $row[0]; if ( $roll <= 0 ) { return $i; } } return 0; } private static function kv( string $title, array $data ): void { WP_CLI::log( "{$title}:" ); $rows = array(); foreach ( $data as $k => $v ) { $rows[] = array( 'field' => $k, 'value' => is_bool( $v ) ? ( $v ? 'true' : 'false' ) : ( $v ?? '(null)' ), ); } \WP_CLI\Utils\format_items( 'table', $rows, array( 'field', 'value' ) ); } private static function format_value( $value, ?string $format ): string { if ( null === $value || '' === $value ) { return '(none)'; } return match ( $format ) { 'channel' => Saddle_Analytics_Channel::label( (int) $value ), 'device' => Saddle_Analytics_Device::label( (int) $value ), default => (string) $value, }; } } WP_CLI::add_command( 'saddle-analytics', 'Saddle_Analytics_CLI' );