{ mcp_requests, mcp_tool_calls: {tool: n}, * markdown_hits, llms_txt_hits }, trimmed to 30 days. * * Reporting to 42A is opt-in and default OFF (`ael42a_telemetry_opt_in` * = '0'). When connected AND opted in, the daily cron posts yesterday's * aggregate. The plugin makes no other unprompted network calls, ever. */ class Telemetry { const OPTION = 'ael42a_stats'; const OPT_IN = 'ael42a_telemetry_opt_in'; const MAX_DAYS = 30; const SENT_MARKER = 'ael42a_telemetry_last_sent'; const MAX_BOT_KEYS = 100; /** * Hook the daily reporter and the classified-traffic page-view counter. */ public function register_hooks() { add_action( 'ael42a_daily', array( $this, 'maybe_send_daily' ) ); // Priority 30: after Markdown_Controller (1, 20) and Llms_Txt's own // template_redirect handler, both of which `exit` when they serve a // request - so this only ever runs for requests neither intercepted, // i.e. regular page views. Avoids double-counting the same request. add_action( 'template_redirect', array( $this, 'maybe_record_page_view_bot_hit' ), 30 ); } /** * Whether the user opted into telemetry reporting. * * @return bool */ public function is_opted_in() { return '1' === (string) get_option( self::OPT_IN, '0' ); } /** * Increment a scalar counter for today. * * @param string $key One of mcp_requests|markdown_hits|llms_txt_hits. */ public function increment( $key ) { $stats = $this->get_stats(); $today = gmdate( 'Ymd' ); if ( ! isset( $stats[ $today ] ) ) { $stats[ $today ] = $this->empty_day(); } if ( ! isset( $stats[ $today ][ $key ] ) ) { $stats[ $today ][ $key ] = 0; } $stats[ $today ][ $key ]++; $this->save_stats( $stats ); } /** * Increment the per-tool call counter for today. * * @param string $tool Tool name. */ public function increment_tool( $tool ) { $tool = substr( preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $tool ), 0, 64 ); $stats = $this->get_stats(); $today = gmdate( 'Ymd' ); if ( ! isset( $stats[ $today ] ) ) { $stats[ $today ] = $this->empty_day(); } if ( ! isset( $stats[ $today ]['mcp_tool_calls'][ $tool ] ) ) { $stats[ $today ]['mcp_tool_calls'][ $tool ] = 0; } $stats[ $today ]['mcp_tool_calls'][ $tool ]++; $this->save_stats( $stats ); } /** * Classify a User-Agent string against the AI bot taxonomy * (Local_Vitals::get_ai_bots()): case-insensitive substring match of * each known bot name within the UA string. When multiple names match, * the LONGEST matching name wins (more specific identifier). Mirrors * classifyUserAgent() in packages/ael/src/bots.ts. * * @param string $user_agent Raw User-Agent header value. * @return array|null Matching bot row ({name, tier, owner, description}), or null. */ public static function classify_user_agent( $user_agent ) { $ua = strtolower( (string) $user_agent ); if ( '' === $ua ) { return null; } $best = null; foreach ( Local_Vitals::get_ai_bots() as $bot ) { if ( false === strpos( $ua, strtolower( $bot['name'] ) ) ) { continue; } if ( null === $best || strlen( $bot['name'] ) > strlen( $best['name'] ) ) { $best = $bot; } } return $best; } /** * Increment today's counter for a classified bot name, bounded to * MAX_BOT_KEYS distinct bot names per day (extras silently dropped). * * @param string $bot_name Bot name (from classify_user_agent()). */ public function record_bot_hit( $bot_name ) { $bot_name = substr( preg_replace( '/[^a-zA-Z0-9_\-. ]/', '', (string) $bot_name ), 0, 64 ); if ( '' === $bot_name ) { return; } $stats = $this->get_stats(); $today = gmdate( 'Ymd' ); if ( ! isset( $stats[ $today ] ) ) { $stats[ $today ] = $this->empty_day(); } if ( ! isset( $stats[ $today ]['bot_hits'] ) || ! is_array( $stats[ $today ]['bot_hits'] ) ) { $stats[ $today ]['bot_hits'] = array(); } if ( ! isset( $stats[ $today ]['bot_hits'][ $bot_name ] ) ) { if ( count( $stats[ $today ]['bot_hits'] ) >= self::MAX_BOT_KEYS ) { return; // Bounded: drop new distinct bot names beyond the cap. } $stats[ $today ]['bot_hits'][ $bot_name ] = 0; } $stats[ $today ]['bot_hits'][ $bot_name ]++; $this->save_stats( $stats ); } /** * Classify the current request's User-Agent header and record a bot hit * when it matches a known AI bot. Cheap early-exit when the UA doesn't * match. No paths or PII are ever logged - only the classified bot * name/count. Called from the markdown/llms.txt/MCP handlers and (for * regular page views) from maybe_record_page_view_bot_hit() below. */ public function record_bot_hit_from_request() { $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $ua ) { return; } $bot = self::classify_user_agent( $ua ); if ( null === $bot ) { return; } $this->record_bot_hit( $bot['name'] ); } /** * Front-end-only wrapper around record_bot_hit_from_request() for * regular page views (hooked at a low template_redirect priority so it * never double-counts requests the markdown/llms.txt handlers already * served and exited on). */ public function maybe_record_page_view_bot_hit() { if ( is_admin() ) { return; } $this->record_bot_hit_from_request(); } /** * Classified AI-traffic summary for the admin dashboard: tier totals * (training/search/agentic) and the top 5 bots by hit count, over the * last $days days of local counters. * * @param int $days Number of trailing days to aggregate. * @return array {tiers: {training,search,agentic}, top: [{name,tier,hits}]} */ public function bot_traffic_summary( $days = 7 ) { $stats = $this->get_stats(); $tier_by_name = array(); foreach ( Local_Vitals::get_ai_bots() as $bot ) { $tier_by_name[ $bot['name'] ] = $bot['tier']; } $tier_totals = array( 'training' => 0, 'search' => 0, 'agentic' => 0, ); $totals = array(); for ( $i = 0; $i < $days; $i++ ) { $key = gmdate( 'Ymd', time() - $i * DAY_IN_SECONDS ); if ( ! isset( $stats[ $key ]['bot_hits'] ) || ! is_array( $stats[ $key ]['bot_hits'] ) ) { continue; } foreach ( $stats[ $key ]['bot_hits'] as $name => $count ) { $count = (int) $count; $totals[ $name ] = isset( $totals[ $name ] ) ? $totals[ $name ] + $count : $count; $tier = isset( $tier_by_name[ $name ] ) ? $tier_by_name[ $name ] : 'agentic'; if ( isset( $tier_totals[ $tier ] ) ) { $tier_totals[ $tier ] += $count; } } } arsort( $totals ); $top = array(); foreach ( $totals as $name => $count ) { if ( count( $top ) >= 5 ) { break; } $top[] = array( 'name' => $name, 'tier' => isset( $tier_by_name[ $name ] ) ? $tier_by_name[ $name ] : 'agentic', 'hits' => $count, ); } return array( 'tiers' => $tier_totals, 'top' => $top, ); } /** * All stored daily aggregates, newest last. * * @return array */ public function get_stats() { $stats = get_option( self::OPTION, array() ); return is_array( $stats ) ? $stats : array(); } /** * The last N days of counters, shaped for the admin traffic table. * * @param int $days Number of days. * @return array[] Rows {date, mcp_requests, markdown_hits, llms_txt_hits, tool_calls_total}. */ public function recent_days( $days = 7 ) { $stats = $this->get_stats(); $rows = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $key = gmdate( 'Ymd', time() - $i * DAY_IN_SECONDS ); $day = isset( $stats[ $key ] ) ? $stats[ $key ] : $this->empty_day(); $tool_total = 0; if ( ! empty( $day['mcp_tool_calls'] ) && is_array( $day['mcp_tool_calls'] ) ) { $tool_total = array_sum( $day['mcp_tool_calls'] ); } $rows[] = array( 'date' => gmdate( 'Y-m-d', time() - $i * DAY_IN_SECONDS ), 'mcp_requests' => isset( $day['mcp_requests'] ) ? (int) $day['mcp_requests'] : 0, 'markdown_hits' => isset( $day['markdown_hits'] ) ? (int) $day['markdown_hits'] : 0, 'llms_txt_hits' => isset( $day['llms_txt_hits'] ) ? (int) $day['llms_txt_hits'] : 0, 'tool_calls_total' => (int) $tool_total, ); } return $rows; } /** * Daily cron: post yesterday's aggregate to 42A when connected + opted in. */ public function maybe_send_daily() { if ( ! $this->is_opted_in() ) { return; } if ( '' === (string) get_option( 'ael42a_site_token', '' ) ) { return; } $yesterday_key = gmdate( 'Ymd', time() - DAY_IN_SECONDS ); $yesterday_date = gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ); if ( get_option( self::SENT_MARKER, '' ) === $yesterday_date ) { return; // Already reported. } $stats = $this->get_stats(); $day = isset( $stats[ $yesterday_key ] ) ? $stats[ $yesterday_key ] : $this->empty_day(); $payload = array( // Re-assert consent on every report: the server keeps its own // opt-in flag and rejects data unless it has been enabled. 'opt_in' => true, 'date' => $yesterday_date, 'mcp_requests' => isset( $day['mcp_requests'] ) ? (int) $day['mcp_requests'] : 0, 'markdown_hits' => isset( $day['markdown_hits'] ) ? (int) $day['markdown_hits'] : 0, 'llms_txt_hits' => isset( $day['llms_txt_hits'] ) ? (int) $day['llms_txt_hits'] : 0, 'tool_calls' => isset( $day['mcp_tool_calls'] ) && is_array( $day['mcp_tool_calls'] ) ? $day['mcp_tool_calls'] : new \stdClass(), ); // Additive/optional: only included when there is at least one // classified bot hit for the day. if ( ! empty( $day['bot_hits'] ) && is_array( $day['bot_hits'] ) ) { $payload['bot_hits'] = $day['bot_hits']; } $client = new Api_Client(); $result = $client->send_telemetry( $payload ); if ( ! is_wp_error( $result ) ) { update_option( self::SENT_MARKER, $yesterday_date, false ); } } /** * Empty per-day structure. * * @return array */ private function empty_day() { return array( 'mcp_requests' => 0, 'mcp_tool_calls' => array(), 'markdown_hits' => 0, 'llms_txt_hits' => 0, 'bot_hits' => array(), ); } /** * Persist stats, trimmed to the newest MAX_DAYS entries, autoload off. * * @param array $stats Stats map date(Ymd) => day. */ private function save_stats( $stats ) { ksort( $stats ); if ( count( $stats ) > self::MAX_DAYS ) { $stats = array_slice( $stats, -self::MAX_DAYS, null, true ); } if ( false === add_option( self::OPTION, $stats, '', 'no' ) ) { update_option( self::OPTION, $stats, 'no' ); } } }