get( 'usage_tracking', 'admin', true ); } /** * The anonymous site ID. * * Generated whenever one does not exist, so a manually deleted ID * regenerates automatically while tracking remains enabled. * * @return string */ public function site_id() { $site_id = Options::instance()->get( 'telemetry_site_id' ); if ( ! $site_id || ! wp_is_uuid( $site_id ) ) { $site_id = wp_generate_uuid4(); Options::instance()->update( 'telemetry_site_id', $site_id ); } return $site_id; } /** * The usage server API base URL. * * @return string */ public static function server_url() { /** * Filters the usage tracking server URL. Used in testing. * * @param string $url The API base URL. */ return untrailingslashit( apply_filters( 'adcmdr_telemetry_server_url', 'https://wpadcommander.com/wp-json/adcmdr-usage/v1' ) ); } /** * Schedule or unschedule the weekly send to match the setting. * * The first run is offset by a random interval within the week so that * sends are distributed across sites rather than clustered. * * @return void */ public function maybe_schedule() { $scheduled = wp_next_scheduled( self::cron_hook() ); if ( $this->is_enabled() && ! $scheduled ) { wp_schedule_event( time() + wp_rand( 0, WEEK_IN_SECONDS ), 'weekly', self::cron_hook() ); } elseif ( ! $this->is_enabled() && $scheduled ) { wp_clear_scheduled_hook( self::cron_hook() ); } /** * Tracking is enabled but nothing has ever been sent (e.g. imported * settings, or the initial event was lost) and no send is imminent: * schedule the first batch now rather than waiting out the weekly cron. */ if ( $this->is_enabled() && ! Options::instance()->get( 'telemetry_last_send' ) ) { $next = wp_next_scheduled( self::cron_hook() ); if ( ! $next || $next > time() + HOUR_IN_SECONDS ) { wp_schedule_single_event( time() + 60, self::cron_hook() ); } } } /** * React to the admin settings group being updated. * * @param mixed $old_value The previous option value. * @param mixed $value The new option value. * * @return void */ public function option_updated( $old_value, $value ) { $was = is_array( $old_value ) && isset( $old_value['usage_tracking'] ) ? Util::truthy( $old_value['usage_tracking'] ) : false; $now = is_array( $value ) && isset( $value['usage_tracking'] ) ? Util::truthy( $value['usage_tracking'] ) : false; if ( $was === $now ) { return; } if ( $now ) { $this->enable(); } else { $this->disable(); } } /** * React to the admin settings group being created. * * @param string $option The option name. * @param mixed $value The option value. * * @return void */ public function option_added( $option, $value ) { if ( is_array( $value ) && isset( $value['usage_tracking'] ) && Util::truthy( $value['usage_tracking'] ) ) { $this->enable(); } } /** * Enable tracking: schedule the weekly send and send a first report shortly. * * @return void */ public function enable() { if ( ! wp_next_scheduled( self::cron_hook() ) ) { wp_schedule_event( time() + wp_rand( 0, WEEK_IN_SECONDS ), 'weekly', self::cron_hook() ); } wp_schedule_single_event( time() + 30, self::cron_hook() ); } /** * Disable tracking: unschedule, ask the server to delete this site's data, * and discard the local site ID so any future opt-in is a fresh identity. * * @return void */ public function disable() { wp_clear_scheduled_hook( self::cron_hook() ); $site_id = Options::instance()->get( 'telemetry_site_id' ); if ( $site_id && wp_is_uuid( $site_id ) ) { /** * Fire-and-forget: this runs during an interactive settings save, and * the response is not used. The short timeout caps how long an * unreachable server can delay the request (blocking => false alone * does not cap the connect wait); a lost delete is covered by * server-side data retention. */ wp_remote_post( self::server_url() . '/delete', array( 'blocking' => false, 'timeout' => 1, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'site_id' => $site_id ) ), ) ); Options::instance()->delete( 'telemetry_site_id' ); } Options::instance()->delete( 'telemetry_last_send' ); } /** * Send the usage report to the server. * * @return void */ public function send_usage_data() { if ( ! $this->is_enabled() ) { return; } /** * Throttle: never send more than once per day, even if cron misfires. */ $last_send = absint( Options::instance()->get( 'telemetry_last_send' ) ); if ( $last_send && ( time() - $last_send ) < DAY_IN_SECONDS ) { return; } wp_remote_post( self::server_url() . '/report', array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $this->collect() ), ) ); Options::instance()->update( 'telemetry_last_send', time() ); } /** * Build the anonymous usage payload. * * @return array */ public function collect() { $data = array( 'schema' => self::SCHEMA_VERSION, 'site_id' => $this->site_id(), 'generated' => gmdate( 'Y-m-d H:i:s' ), 'plugin' => $this->plugin_data(), 'env' => $this->env_data(), 'plugins' => $this->plugins_data(), 'content' => $this->content_data(), 'targeting' => $this->targeting_data(), 'settings' => $this->settings_data(), ); /** * Filters the anonymous usage payload. Pro adds its data here. * * IMPORTANT: values must remain anonymous. Never add URLs, emails, * license keys, account IDs, or targeting rule values. * * @param array $data The payload. */ return apply_filters( 'adcmdr_telemetry_data', $data ); } /** * Ad Commander plugin family data. * * @return array */ private function plugin_data() { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $pro_file = 'ad-commander-pro/ad-commander-pro.php'; $tools_file = 'ad-commander-tools/ad-commander-tools.php'; return array( 'version' => AdCommander::version(), 'db_version' => Util::get_dbversion(), 'pro' => array( 'installed' => isset( $all_plugins[ $pro_file ] ), 'active' => ProBridge::instance()->is_pro_loaded(), 'version' => isset( $all_plugins[ $pro_file ] ) ? $all_plugins[ $pro_file ]['Version'] : null, 'licensed' => null, // Populated by Pro (boolean only). ), 'tools' => array( 'installed' => isset( $all_plugins[ $tools_file ] ), 'active' => is_plugin_active( $tools_file ), 'version' => isset( $all_plugins[ $tools_file ] ) ? $all_plugins[ $tools_file ]['Version'] : null, ), ); } /** * Environment data (subset of SystemInfo; no URLs). * * @return array */ private function env_data() { $env = SystemInfo::environment(); $server = SystemInfo::server(); $db = SystemInfo::database(); $theme = SystemInfo::theme(); return array( 'wp_version' => $env['wp_version']['value'], 'multisite' => (bool) $env['multisite']['value'], 'locale' => $env['locale']['value'], 'wp_cron' => (bool) $env['wp_cron']['value'], 'wp_memory' => $env['wp_memory']['value'], 'php_version' => $server['php_version']['value'], 'php_memory' => $server['php_memory_limit']['value'], 'curl' => function_exists( 'curl_version' ), 'db_system' => $db['system']['value'], 'db_version' => $db['version']['value'], 'theme' => $theme['name']['value'], 'theme_version' => $theme['version']['value'], ); } /** * Active plugins: count plus a few relevant booleans. The full plugin * list is intentionally not sent (it could fingerprint a site). * * @return array */ private function plugins_data() { $active = SystemInfo::active_plugins(); return array( 'active_count' => count( $active ), 'woocommerce' => class_exists( 'WooCommerce', false ), 'amp' => function_exists( 'amp_is_enabled' ) || defined( 'AMP__VERSION' ), ); } /** * Content counts. * * @return array */ private function content_data() { global $wpdb; $wo_meta = new \ADCmdr\Vendor\WOAdminFramework\WOMeta( AdCommander::ns() ); $ad_counts = wp_count_posts( AdCommander::posttype_ad() ); $placement_counts = wp_count_posts( AdCommander::posttype_placement() ); /** * Published ads per ad type. */ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Aggregate counts for anonymous usage reporting; runs weekly via cron. $ads_by_type = array(); $type_rows = $wpdb->get_results( $wpdb->prepare( "SELECT pm.meta_value AS adtype, COUNT(*) AS total FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_type = %s AND p.post_status = 'publish' GROUP BY pm.meta_value", $wo_meta->make_key( 'adtype' ), AdCommander::posttype_ad() ) ); foreach ( $type_rows as $row ) { if ( $row->adtype !== '' ) { $ads_by_type[ sanitize_key( $row->adtype ) ] = absint( $row->total ); } } /** * Placements per position. */ $placements_by_position = array(); $position_rows = $wpdb->get_results( $wpdb->prepare( "SELECT pm.meta_value AS position, COUNT(*) AS total FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_type = %s AND p.post_status = 'publish' GROUP BY pm.meta_value", $wo_meta->make_key( 'placement_position' ), AdCommander::posttype_placement() ) ); foreach ( $position_rows as $row ) { if ( $row->position !== '' ) { $placements_by_position[ sanitize_key( $row->position ) ] = absint( $row->total ); } } /** * Ads created through WooCommerce purchases. */ $ads_purchased = absint( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT pm.post_id) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value != '' AND p.post_type = %s AND p.post_status != 'trash'", $wo_meta->make_key( 'woo_order_id' ), AdCommander::posttype_ad() ) ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return array( 'ads' => isset( $ad_counts->publish ) ? absint( $ad_counts->publish ) : 0, 'ads_by_type' => $ads_by_type, 'ads_purchased' => $ads_purchased, 'groups' => absint( wp_count_terms( array( 'taxonomy' => AdCommander::tax_group(), 'hide_empty' => false ) ) ), 'campaigns' => absint( wp_count_terms( array( 'taxonomy' => AdCommander::tax_campaign(), 'hide_empty' => false ) ) ), 'brands' => absint( wp_count_terms( array( 'taxonomy' => AdCommander::tax_advertiser(), 'hide_empty' => false ) ) ), 'placements' => isset( $placement_counts->publish ) ? absint( $placement_counts->publish ) : 0, 'placements_by_position' => $placements_by_position, ); } /** * Targeting rule counts per target type. Rule values are never included. * * @return array */ private function targeting_data() { global $wpdb; $wo_meta = new \ADCmdr\Vendor\WOAdminFramework\WOMeta( AdCommander::ns() ); $counts = array( 'content' => array(), 'visitor' => array(), ); $meta_keys = array( 'content' => $wo_meta->make_key( 'content_conditions' ), 'visitor' => $wo_meta->make_key( 'visitor_conditions' ), ); foreach ( $meta_keys as $type => $meta_key ) { // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Aggregate counts for anonymous usage reporting; runs weekly via cron. $rows = $wpdb->get_col( $wpdb->prepare( "SELECT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_status = 'publish' AND p.post_type IN (%s, %s)", $meta_key, AdCommander::posttype_ad(), AdCommander::posttype_placement() ) ); $term_rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->termmeta} WHERE meta_key = %s", $meta_key ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching foreach ( array_merge( $rows, $term_rows ) as $raw ) { $rules = maybe_unserialize( $raw ); if ( ! is_array( $rules ) ) { continue; } foreach ( $rules as $rule ) { if ( ! is_array( $rule ) || empty( $rule['target'] ) || ! is_string( $rule['target'] ) ) { continue; } $target = sanitize_key( $rule['target'] ); if ( ! isset( $counts[ $type ][ $target ] ) ) { $counts[ $type ][ $target ] = 0; } ++$counts[ $type ][ $target ]; } } } return $counts; } /** * Feature/settings booleans. Account IDs and keys are reported only as * in-use booleans, never as values. * * @return array */ private function settings_data() { $options = Options::instance(); return array( 'render_method' => (string) $options->get( 'render_method', 'general', false, 'serverside' ), 'ads_txt_in_use' => (bool) $options->get( 'ads_txt_records', 'general' ), 'consent_required' => $options->get( 'consent_required', 'privacy', true ), 'consent_method' => Consent::instance()->consent_method() ? Consent::instance()->consent_method() : 'none', 'local_tracking' => $options->get( 'enable_local_tracking', 'tracking', true ), 'ga_tracking' => $options->get( 'enable_ga_tracking', 'tracking', true ), 'ga_in_use' => (bool) $options->get( 'ga_tracking_ids', 'tracking' ), 'click_fraud' => $options->get( 'click_fraud_enable', 'tracking', true ), 'adsense_in_use' => (bool) $options->get( 'adsense_account', 'adsense' ), 'geo_service' => (string) $options->get( 'geo_service', 'targeting', false, '' ), 'maxmind_in_use' => (bool) $options->get( 'maxmind_license_key', 'targeting' ), 'selling_enabled' => $options->get( 'enable_woo_selling', 'selling', true ), 'purchaser_management' => $options->get( 'woo_purchaser_management', 'selling', true ), 'brands_disabled' => $options->get( 'disable_advertisers_tax', 'admin', true ), ); } }