' . esc_html( sprintf( esc_html__( 'The city database could not be downloaded: %s', 'bobr-analytics' ), (string) get_transient( 'bobr_geo_err' ) ) ) . '
' . esc_html__( 'The AI assistant is detected by the referring address and by the utm_source tag (ChatGPT adds it automatically). Some visits from AI apps arrive without a referrer and are counted as direct.', 'bobr-analytics' ) . '
';
}
private static function sec_bots() {
global $wpdb;
$r = self::$r;
$b = Bobr_DB::t( 'bots' );
$w = $r->day_where();
$rows = $wpdb->get_results( 'SELECT ' . $r->bucket_sql() . " d, kind, COUNT(*) n FROM $b WHERE $w GROUP BY d, kind" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$by = array();
foreach ( $rows as $x ) {
$by[ $x->kind ][ $x->d ] = (int) $x->n;
}
$labels = $r->buckets();
$series = array();
foreach ( array( 'search', 'ai', 'seo', 'other', 'unknown' ) as $k ) {
if ( ! empty( $by[ $k ] ) ) {
$vals = array();
foreach ( $labels as $d ) {
$vals[] = $by[ $k ][ $d ] ?? 0;
}
$series[] = array( Bobr_Util::label( $k ), $vals );
}
}
if ( ! bobr_settings()['bots'] ) {
echo '
' . esc_html__( 'Bot tracking is turned off in the settings.', 'bobr-analytics' ) . '
';
}
/* translators: %s: chart step: by day, by week or by month */
self::chart( sprintf( esc_html__( 'Bot requests %s', 'bobr-analytics' ), self::per() ), $labels, $series ? $series : array( array( esc_html__( 'Bots', 'bobr-analytics' ), array_fill( 0, count( $labels ), 0 ) ) ) );
$rows = array();
foreach ( $wpdb->get_results( "SELECT bot, kind, COUNT(*) n, COUNT(DISTINCT path) pages, SUM(status >= 400) errs, MAX(ts) last FROM $b WHERE $w GROUP BY bot, kind ORDER BY n DESC LIMIT 500" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$rows[] = array( 'bot' => $x->bot, 'kind' => Bobr_Util::label( $x->kind ), 'n' => $x->n, 'pages' => $x->pages, 'errs' => $x->errs, 'last' => mysql2date( 'd.m H:i', $x->last ) );
}
self::table( esc_html__( 'Bots', 'bobr-analytics' ), array( 'bot' => array( esc_html__( 'Bot', 'bobr-analytics' ) ), 'kind' => array( esc_html__( 'Type', 'bobr-analytics' ) ), 'n' => array( esc_html__( 'Requests', 'bobr-analytics' ), 'bar' ), 'pages' => array( esc_html__( 'Distinct pages', 'bobr-analytics' ), 'num' ), 'errs' => array( esc_html__( '4xx-5xx errors', 'bobr-analytics' ), 'num' ), 'last' => array( esc_html__( 'Last visit', 'bobr-analytics' ) ) ), $rows, 'roboty' );
$rows = array();
foreach ( $wpdb->get_results( "SELECT bot, path, COUNT(*) n, MAX(ts) last FROM $b WHERE $w AND kind = 'ai' GROUP BY bot, path ORDER BY n DESC LIMIT 500" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$rows[] = array( 'bot' => $x->bot, 'p' => $x->path, 'n' => $x->n, 'last' => mysql2date( 'd.m H:i', $x->last ) );
}
self::table( esc_html__( 'AI agents: what they read', 'bobr-analytics' ), array( 'bot' => array( esc_html__( 'Agent', 'bobr-analytics' ) ), 'p' => array( esc_html__( 'Page', 'bobr-analytics' ) ), 'n' => array( esc_html__( 'Requests', 'bobr-analytics' ), 'bar' ), 'last' => array( esc_html__( 'Last time', 'bobr-analytics' ) ) ), $rows, 'ai-agenty' );
$grid = array();
foreach ( $wpdb->get_results( "SELECT WEEKDAY(ts)+1 wd, HOUR(ts) hh, COUNT(*) n FROM $b WHERE $w GROUP BY wd, hh" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$grid[ (int) $x->wd ][ (int) $x->hh ] = (int) $x->n;
}
echo '
' . esc_html__( 'When bots come: days of the week and hours', 'bobr-analytics' ) . '
' . wp_kses( Bobr_Charts::heat( $grid ), Bobr_Charts::allowed() ) . '';
$rows = array();
foreach ( $wpdb->get_results( "SELECT path, COUNT(*) n, COUNT(DISTINCT bot) bots, SUM(status = 404) nf FROM $b WHERE $w GROUP BY path ORDER BY n DESC LIMIT 500" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$rows[] = array( 'p' => $x->path, 'n' => $x->n, 'bots' => $x->bots, 'nf' => $x->nf );
}
self::table( esc_html__( 'Pages that bots request', 'bobr-analytics' ), array( 'p' => array( esc_html__( 'Page', 'bobr-analytics' ) ), 'n' => array( esc_html__( 'Requests', 'bobr-analytics' ), 'bar' ), 'bots' => array( esc_html__( 'Distinct bots', 'bobr-analytics' ), 'num' ), 'nf' => array( esc_html__( '404 responses', 'bobr-analytics' ), 'num' ) ), $rows, 'roboty-stranicy' );
}
private static function sec_geo() {
$r = self::$r;
if ( ! Bobr_Geo::has_db() ) {
echo '
' . esc_html__( 'The city database is not connected: country and region are estimated from the browser time zone. You can connect the database in Settings.', 'bobr-analytics' ) . '
';
}
/* translators: %s: chart step: by day, by week or by month */
self::chart( sprintf( esc_html__( 'Top five cities %s', 'bobr-analytics' ), self::per() ), $r->buckets(), self::named( $r->split( 'city' ) ) );
echo '
';
}
private static function sec_time() {
global $wpdb;
$r = self::$r;
$v = Bobr_DB::t( 'visits' );
$grid = array();
foreach ( $wpdb->get_results( "SELECT WEEKDAY(started)+1 wd, HOUR(started) hh, COUNT(*) n FROM $v v WHERE " . $r->where() . ' GROUP BY wd, hh' ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$grid[ (int) $x->wd ][ (int) $x->hh ] = (int) $x->n;
}
echo '
' . esc_html__( 'Visits by day of the week and hour', 'bobr-analytics' ) . '
';
foreach ( $types as $t => $l ) {
self::card( $l, number_format_i18n( (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $e e WHERE $w AND type = %s", $t ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
}
echo '
';
$by = array();
foreach ( $wpdb->get_results( 'SELECT ' . $r->bucket_sql( 'e' ) . " d, type, COUNT(*) n FROM $e e WHERE $w AND type IN ('tel','mail','out','form_sent','form_drop') GROUP BY d, type" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$by[ $x->type ][ $x->d ] = (int) $x->n;
}
$series = array();
foreach ( $types as $t => $l ) {
if ( ! empty( $by[ $t ] ) ) {
$vals = array();
foreach ( $r->buckets() as $d ) {
$vals[] = $by[ $t ][ $d ] ?? 0;
}
$series[] = array( $l, $vals );
}
}
/* translators: %s: chart step: by day, by week or by month */
self::chart( sprintf( esc_html__( 'Visitor actions %s', 'bobr-analytics' ), self::per() ), $r->buckets(), $series );
$rows = array();
foreach ( $wpdb->get_results( "SELECT name, COUNT(*) n, COUNT(DISTINCT path) pages FROM $e e WHERE $w AND type = 'out' GROUP BY name ORDER BY n DESC LIMIT 1000" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$rows[] = array( 'u' => $x->name, 'n' => $x->n, 'pages' => $x->pages );
}
self::table( esc_html__( 'Outbound links', 'bobr-analytics' ), array( 'u' => array( esc_html__( 'Destination', 'bobr-analytics' ) ), 'n' => array( esc_html__( 'Clicks', 'bobr-analytics' ), 'bar' ), 'pages' => array( esc_html__( 'From distinct pages', 'bobr-analytics' ), 'num' ) ), $rows, 'vneshnie-ssylki' );
$rows = array();
foreach ( $wpdb->get_results( "SELECT name, path, SUM(type = 'form_sent') s, SUM(type = 'form_drop') dr FROM $e e WHERE $w AND type IN ('form_sent','form_drop') GROUP BY name, path ORDER BY s DESC, dr DESC LIMIT 1000" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$rows[] = array( 'f' => $x->name, 'p' => $x->path, 's' => $x->s, 'dr' => $x->dr );
}
self::table( esc_html__( 'Forms', 'bobr-analytics' ), array( 'f' => array( esc_html__( 'Form', 'bobr-analytics' ) ), 'p' => array( esc_html__( 'Page', 'bobr-analytics' ) ), 's' => array( esc_html__( 'Submitted', 'bobr-analytics' ), 'bar' ), 'dr' => array( esc_html__( 'Started and abandoned', 'bobr-analytics' ), 'num' ) ), $rows, 'formy' );
$rows = array();
foreach ( $wpdb->get_results( "SELECT path, SUM(type = 'tel') t, SUM(type = 'mail') m FROM $e e WHERE $w AND type IN ('tel','mail') GROUP BY path ORDER BY t DESC LIMIT 500" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$rows[] = array( 'p' => $x->path, 't' => $x->t, 'm' => $x->m );
}
self::table( esc_html__( 'Where visitors click phone and email links', 'bobr-analytics' ), array( 'p' => array( esc_html__( 'Page', 'bobr-analytics' ) ), 't' => array( esc_html__( 'Calls', 'bobr-analytics' ), 'bar' ), 'm' => array( esc_html__( 'Email', 'bobr-analytics' ), 'num' ) ), $rows, 'telefon-pochta' );
echo '
' . esc_html__( 'Phone numbers, email addresses and form field values are never recorded: only the click itself and the form name.', 'bobr-analytics' ) . '
';
}
private static function sec_nf() {
global $wpdb;
$r = self::$r;
$e = Bobr_DB::t( 'events' );
$rows = array();
foreach ( $wpdb->get_results( "SELECT path, name, COUNT(*) n, MAX(ts) last FROM $e e WHERE " . $r->day_where( 'e' ) . " AND type = 'nf' GROUP BY path, name ORDER BY n DESC LIMIT 1000" ) as $x ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$rows[] = array( 'p' => $x->path, 'from' => $x->name, 'n' => $x->n, 'last' => mysql2date( 'd.m H:i', $x->last ) );
}
self::table( esc_html__( '404 pages seen by people: where they came from', 'bobr-analytics' ), array( 'p' => array( esc_html__( 'Missing address', 'bobr-analytics' ) ), 'from' => array( esc_html__( 'Came from', 'bobr-analytics' ) ), 'n' => array( esc_html__( 'Times', 'bobr-analytics' ), 'bar' ), 'last' => array( esc_html__( 'Last time', 'bobr-analytics' ) ) ), $rows, 'oshibki-404' );
echo '
' . esc_html__( '404 errors seen by bots are in the Bots section.', 'bobr-analytics' ) . '
' . esc_html__( 'Green is good, yellow needs improvement, red is poor (Google Core Web Vitals thresholds). 75th percentile: three out of four visitors got this result or better.', 'bobr-analytics' ) . '
';
}
private static function sec_now() {
global $wpdb;
$v = Bobr_DB::t( 'visits' );
$since = wp_date( 'Y-m-d H:i:s', time() - 1800 );
$list = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $v WHERE last_seen >= %s ORDER BY last_seen DESC LIMIT 300", $since ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own statistics tables: table names come from $wpdb->prefix, columns and SQL expressions are fixed in code, user values are bound with $wpdb->prepare() in Bobr_Report::where().
$five = 0;
foreach ( $list as $x ) {
if ( strtotime( $x->last_seen ) >= strtotime( wp_date( 'Y-m-d H:i:s' ) ) - 300 ) {
$five++;
}
}
echo '
';
self::card( esc_html__( 'Visits in the last 30 minutes', 'bobr-analytics' ), number_format_i18n( count( $list ) ) );
self::card( esc_html__( 'Active in the last 5 minutes', 'bobr-analytics' ), number_format_i18n( $five ) );
echo '
';
if ( Bobr_Geo::has_db() ) {
$up = (int) get_option( 'bobr_geo_updated' );
$size = size_format( filesize( Bobr_Geo::db_file() ) );
if ( $up ) {
/* translators: 1: database file size, for example 130 MB; 2: date of the last update */
$msg = sprintf( esc_html__( 'Connected: %1$s, updated %2$s.', 'bobr-analytics' ), $size, wp_date( get_option( 'date_format' ), $up ) );
} else {
/* translators: %s: database file size, for example 130 MB */
$msg = sprintf( esc_html__( 'Connected: %s.', 'bobr-analytics' ), $size );
}
echo '
' . esc_html( $msg ) . '
';
} else {
echo '
' . esc_html__( 'Not connected. Until it is, country and region are estimated from the browser time zone.', 'bobr-analytics' ) . '
';
}
echo '
' . esc_html__( 'The DB-IP IP to City Lite file from db-ip.com: about 60 MB to download, about 130 MB on disk after unpacking. Free, no registration, updated monthly. The button makes a single request to db-ip.com, visitor data is never sent anywhere.', 'bobr-analytics' ) . '
' . ( defined( 'WP_CACHE' ) && WP_CACHE ? esc_html__( 'This site uses a page cache: bots on cached pages are not counted, people are counted in full.', 'bobr-analytics' ) : esc_html__( 'This site has no page cache: bots are counted in full.', 'bobr-analytics' ) ) . '
';
echo '
' . esc_html__( 'About', 'bobr-analytics' ) . '
';
/* translators: %s: plugin version number */
echo '