PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-usage-analytics-endpoint.php +199 -65 2.0.12.7.0 View file →
@@ -94,8 +94,12 @@
94 94 * Model IDs sourced from https://docs.anthropic.com/en/docs/about-claude/models
95 95 */
96 96 private const CLAUDE_PRICING = [
97 97 // Current models (recommended)
98 + 'claude-opus-5' => [
99 + 'input' => 5.00,
100 + 'output' => 25.00
101 + ],
98 102 'claude-opus-4-8' => [
99 103 'input' => 5.00,
100 104 'output' => 25.00
101 105 ],
@@ -144,8 +148,13 @@
144 148 'gemini-3.1-pro' => [
145 149 'input' => 2.00,
146 150 'output' => 12.00
147 151 ],
152 + // The id the UI offers; 3.1 Pro ships only under -preview.
153 + 'gemini-3.1-pro-preview' => [
154 + 'input' => 2.00,
155 + 'output' => 12.00
156 + ],
148 157 'gemini-3.5-flash' => [
149 158 'input' => 1.50,
150 159 'output' => 9.00
151 160 ],
@@ -192,8 +201,17 @@
192 201 'openai/gpt-4o-mini' => [
193 202 'input' => 0.15,
194 203 'output' => 0.60
195 204 ],
205 + 'anthropic/claude-sonnet-5' => [
206 + 'input' => 3.00,
207 + 'output' => 15.00
208 + ],
209 + 'google/gemini-3.5-flash' => [
210 + 'input' => 1.50,
211 + 'output' => 9.00
212 + ],
213 + // Retired upstream, kept so historical usage rows still price correctly.
196 214 'anthropic/claude-3.5-sonnet' => [
197 215 'input' => 3.00,
198 216 'output' => 15.00
199 217 ],
@@ -272,18 +290,28 @@
272 290 'type' => 'string',
273 291 'enum' => ['7d', '30d', '90d', 'all'],
274 292 'sanitize_callback' => 'sanitize_key'
275 293 ],
276 - 'group_by' => [
277 - 'default' => 'day',
278 - 'type' => 'string',
279 - 'enum' => ['day', 'week', 'month'],
280 - 'sanitize_callback' => 'sanitize_key'
281 - ],
282 294 'user_id' => [
283 295 'default' => 0,
284 296 'type' => 'integer',
285 297 'sanitize_callback' => 'absint'
298 + ],
299 + // Declared because the handler reads them. They were validated
300 + // only by the handler's own clamping, so they had no type
301 + // coercion and did not appear in the endpoint's schema.
302 + 'page' => [
303 + 'default' => 1,
304 + 'type' => 'integer',
305 + 'minimum' => 1,
306 + 'sanitize_callback' => 'absint'
307 + ],
308 + 'per_page' => [
309 + 'default' => 20,
310 + 'type' => 'integer',
311 + 'minimum' => 10,
312 + 'maximum' => 100,
313 + 'sanitize_callback' => 'absint'
286 314 ]
287 315 ]
288 316 ]);
289 317
@@ -362,9 +390,8 @@
362 390 'ai_actions' => $ai_metrics['total_actions'],
363 391 'features_used_count' => $ai_metrics['features_used_count'],
364 392 'most_used_feature' => $ai_metrics['most_used_feature'],
365 393 'most_used_count' => $ai_metrics['most_used_count'],
366 - 'success_rate' => $ai_metrics['success_rate'],
367 394 'content_briefs' => $brief_metrics['total_briefs'],
368 395 'feature_breakdown' => $ai_metrics['feature_breakdown'],
369 396 'provider_breakdown' => $cost_data['by_provider']
370 397 ],
@@ -428,8 +455,34 @@
428 455 return true;
429 456 }
430 457
431 458 /**
459 + * Bind the cache-invalidation listeners for the whole request lifecycle.
460 + *
461 + * The listeners used to be registered only by the constructor, which runs
462 + * on rest_api_init — so usage logged during cron, WP-CLI or an admin-post
463 + * request found no listener and the cached overview rode out its full TTL.
464 + * Called from API\Manager::init() on every request instead.
465 + *
466 + * @since 2.2.1
467 + * @return void
468 + */
469 + public static function boot_cache_invalidation(): void {
470 + static $booted = false;
471 +
472 + if ($booted) {
473 + return;
474 + }
475 +
476 + $booted = true;
477 +
478 + // Constructing the endpoint registers the listeners; the guard in
479 + // setup_cache_invalidation() keeps a later REST construction from
480 + // double-binding them.
481 + new self();
482 + }
483 +
484 + /**
432 485 * Set up cache invalidation hooks
433 486 *
434 487 * @since 1.0.0
435 488 * @return void
@@ -434,8 +487,23 @@
434 487 * @since 1.0.0
435 488 * @return void
436 489 */
437 490 private function setup_cache_invalidation(): void {
491 + // The endpoint is constructed more than once per request — once on
492 + // init via boot_cache_invalidation(), again on rest_api_init, and
493 + // potentially by callers resolving it on demand. Bind once per
494 + // request, or every event invalidates N times.
495 + //
496 + // A has_action() check cannot do this: the callback is [$this, ...]
497 + // and each construction is a different instance, so it never matches.
498 + static $bound = false;
499 +
500 + if ($bound) {
501 + return;
502 + }
503 +
504 + $bound = true;
505 +
438 506 // Invalidate analytics cache when AI usage is logged
439 507 add_action('thinkrank_ai_usage_logged', [$this, 'invalidate_analytics_cache']);
440 508
441 509 // Invalidate analytics cache when SEO scores are updated
@@ -527,46 +595,50 @@
527 595 ];
528 596
529 597 foreach ($usage_data as $usage) {
530 598 $tokens = (int) $usage['tokens_used'];
531 - $provider = $usage['provider'];
599 + $provider = (string) $usage['provider'];
532 600
533 - // Estimate 70% input, 30% output tokens
534 - $input_tokens = $tokens * 0.7;
535 - $output_tokens = $tokens * 0.3;
601 + // Unknown provider: no pricing table, so it cannot be costed. Skip
602 + // rather than let `+=` invent a key that the total below misses.
603 + if (!isset($costs[$provider])) {
604 + continue;
605 + }
536 606
537 - $cost = 0;
607 + // Price at the model the request actually used. Reading only the
608 + // provider meant every row was costed at that provider's default
609 + // model, so this total disagreed with the per-record figures in
610 + // the Usage Breakdown tab — by 4.5x on a gpt-4o-mini workload.
611 + $metadata = !empty($usage['metadata']) ? json_decode((string) $usage['metadata'], true) : [];
612 + $model = is_array($metadata) && !empty($metadata['actual_model'])
613 + ? (string) $metadata['actual_model']
614 + : $this->get_default_model($provider);
538 615
539 - // Use the robust pricing helper for consistent cost calculation
540 - $pricing = $this->get_model_pricing($provider);
541 - if ($pricing) {
542 - $cost = ($input_tokens * $pricing['input'] / 1000000) +
543 - ($output_tokens * $pricing['output'] / 1000000);
544 - $costs[$provider] += $cost;
545 - }
616 + // Single source of truth for per-row pricing, shared with
617 + // get_detailed_usage_breakdown() so both tabs always agree.
618 + $costs[$provider] += $this->calculate_record_cost($provider, $tokens, $model);
546 619 }
547 620
548 621 $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'];
549 622
550 - // Format provider breakdown
551 - $costs['by_provider'] = [
552 - 'openai' => [
553 - 'cost' => round($costs['openai'], 4),
554 - 'percentage' => $costs['total'] > 0 ? round(($costs['openai'] / $costs['total']) * 100, 1) : 0
555 - ],
556 - 'claude' => [
557 - 'cost' => round($costs['claude'], 4),
558 - 'percentage' => $costs['total'] > 0 ? round(($costs['claude'] / $costs['total']) * 100, 1) : 0
559 - ],
560 - 'gemini' => [
561 - 'cost' => round($costs['gemini'], 4),
562 - 'percentage' => $costs['total'] > 0 ? round(($costs['gemini'] / $costs['total']) * 100, 1) : 0
563 - ],
564 - 'openrouter' => [
565 - 'cost' => round($costs['openrouter'], 4),
566 - 'percentage' => $costs['total'] > 0 ? round(($costs['openrouter'] / $costs['total']) * 100, 1) : 0
567 - ]
568 - ];
623 + // Report only providers that actually incurred cost. Emitting all four
624 + // unconditionally meant a site with no AI usage rendered four ranked
625 + // rows at "$0.0000 (0%)" — reading as "four providers were used and
626 + // each was free" — and made the panel's own "No provider cost data"
627 + // empty state unreachable.
628 + $costs['by_provider'] = [];
629 + foreach (['openai', 'claude', 'gemini', 'openrouter'] as $provider) {
630 + if ($costs[$provider] <= 0) {
631 + continue;
632 + }
633 +
634 + $costs['by_provider'][$provider] = [
635 + 'cost' => round($costs[$provider], 4),
636 + 'percentage' => $costs['total'] > 0
637 + ? round(($costs[$provider] / $costs['total']) * 100, 1)
638 + : 0
639 + ];
640 + }
569 641
570 642 return $costs;
571 643 }
572 644
@@ -606,9 +678,9 @@
606 678 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
607 679 $usage_data = $wpdb->get_results(
608 680 $wpdb->prepare(
609 681 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
610 - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
682 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
611 683 $user_id,
612 684 $cutoff
613 685 ),
614 686 ARRAY_A
@@ -617,9 +689,9 @@
617 689 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
618 690 $usage_data = $wpdb->get_results(
619 691 $wpdb->prepare(
620 692 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
621 - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d",
693 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d",
622 694 $user_id
623 695 ),
624 696 ARRAY_A
625 697 );
@@ -630,8 +702,16 @@
630 702 // get_overview_metrics() reads every key unconditionally, so a
631 703 // short array here surfaces as undefined-key warnings and null
632 704 // fields for any user with no AI usage yet (i.e. a fresh install).
633 705 // The values mirror what the loop below produces for zero rows.
706 + //
707 + // The change fields are COMPUTED here rather than hardcoded to 0.
708 + // An empty current window does not mean "nothing changed": a user
709 + // whose usage fell from five actions last month to none this month
710 + // was shown a 0 — rendered as the same em-dash a genuinely flat
711 + // period gets — instead of the -100% that actually happened.
712 + $previous = $this->get_previous_period_data($user_id, $date_condition);
713 +
634 714 return [
635 715 'total_actions' => 0,
636 716 'total_tokens' => 0,
637 717 'feature_breakdown' => [],
@@ -637,12 +717,17 @@
637 717 'feature_breakdown' => [],
638 718 'features_used_count' => 0,
639 719 'most_used_feature' => '',
640 720 'most_used_count' => 0,
641 - 'success_rate' => 0,
642 721 'usage_data' => [],
643 - 'cost_change' => 0,
644 - 'time_saved_change' => 0
722 + 'cost_change' => $this->calculate_percentage_change(
723 + array_key_exists('total_cost', $previous) ? $previous['total_cost'] : 0,
724 + 0.0
725 + ),
726 + 'time_saved_change' => $this->calculate_percentage_change(
727 + array_key_exists('time_saved', $previous) ? $previous['time_saved'] : 0,
728 + 0.0
729 + )
645 730 ];
646 731 }
647 732
648 733 // Calculate feature breakdown and new metrics
@@ -674,20 +759,24 @@
674 759 $most_used_count = $count;
675 760 }
676 761 }
677 762
678 - // Calculate success rate (assuming all logged actions are successful for now)
679 - // In future, we could track failed attempts separately
680 - $success_rate = $total_actions > 0 ? 100 : 0;
763 + // No success rate here on purpose. It used to be
764 + // `$total_actions > 0 ? 100 : 0` — a constant presented as a
765 + // measurement, and one that could only ever read 100% or 0%. Failed
766 + // AI calls are never written to this table, so there is nothing to
767 + // compute a rate from; the KPI card is gone until there is.
681 768
682 769 // Calculate changes from previous period
683 770 $previous_period_data = $this->get_previous_period_data($user_id, $date_condition);
771 + // Note the lack of `?? 0`: a null here means "no previous period",
772 + // and coalescing it to zero would turn that back into a fake 100%.
684 773 $cost_change = $this->calculate_percentage_change(
685 - $previous_period_data['total_cost'] ?? 0,
774 + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0,
686 775 $this->calculate_total_cost($usage_data)
687 776 );
688 777 $time_saved_change = $this->calculate_percentage_change(
689 - $previous_period_data['time_saved'] ?? 0,
778 + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0,
690 779 $this->calculate_time_saved($feature_breakdown)
691 780 );
692 781
693 782 return [
@@ -696,9 +785,8 @@
696 785 'feature_breakdown' => $feature_breakdown,
697 786 'features_used_count' => $features_used_count,
698 787 'most_used_feature' => $most_used_feature,
699 788 'most_used_count' => $most_used_count,
700 - 'success_rate' => $success_rate,
701 789 'usage_data' => $usage_data,
702 790 'cost_change' => $cost_change,
703 791 'time_saved_change' => $time_saved_change
704 792 ];
@@ -740,24 +828,38 @@
740 828 );
741 829 }
742 830
743 831 if (!$result || (int) $result['content_optimized'] === 0) {
832 + // Same reasoning as the empty branch in get_ai_usage_metrics():
833 + // an empty current window is not "no change". A user who
834 + // optimized three posts last month and none this month should
835 + // see -100%, not the em-dash a flat period gets — and for `all`
836 + // there is no previous window, so the change is null.
837 + $previous = $this->get_previous_seo_data($user_id, $date_condition);
838 +
744 839 return [
745 840 'content_optimized' => 0,
746 841 'average_seo_score' => 0,
747 - 'content_optimized_change' => 0,
748 - 'seo_score_change' => 0
842 + 'content_optimized_change' => $this->calculate_percentage_change(
843 + array_key_exists('content_optimized', $previous) ? $previous['content_optimized'] : 0,
844 + 0.0
845 + ),
846 + 'seo_score_change' => $this->calculate_percentage_change(
847 + array_key_exists('average_seo_score', $previous) ? $previous['average_seo_score'] : 0,
848 + 0.0
849 + )
749 850 ];
750 851 }
751 852
752 853 // Calculate changes from previous period
753 854 $previous_seo_data = $this->get_previous_seo_data($user_id, $date_condition);
855 + // As above: no `?? 0`, so a null "no previous period" survives.
754 856 $content_optimized_change = $this->calculate_percentage_change(
755 - $previous_seo_data['content_optimized'] ?? 0,
857 + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0,
756 858 (int) $result['content_optimized']
757 859 );
758 860 $seo_score_change = $this->calculate_percentage_change(
759 - $previous_seo_data['average_seo_score'] ?? 0,
861 + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0,
760 862 round((float) $result['average_score'], 1)
761 863 );
762 864
763 865 return [
@@ -814,9 +916,14 @@
814 916 * @return WP_REST_Response|WP_Error Response object
815 917 */
816 918 public function get_usage_breakdown(WP_REST_Request $request) {
817 919 try {
818 - $user_id = get_current_user_id();
920 + // Mirrors get_overview_metrics(). The two endpoints declared the
921 + // same `user_id` argument but only overview honoured it, so the
922 + // same query string described two different users depending on
923 + // which one you asked. check_permissions() already requires
924 + // manage_options before another user's id is accepted.
925 + $user_id = $request->get_param('user_id') ?: get_current_user_id();
819 926 $period = $request->get_param('period') ?? '30d';
820 927 // `(int)` binds tighter than `??`, so `(int) null` is 0 and the
821 928 // `?? 20` fallback was unreachable — per_page silently defaulted to
822 929 // the max(10, 0) floor of 10 rather than the 20 it advertises, and
@@ -839,9 +946,10 @@
839 946 'pagination' => [
840 947 'page' => $page,
841 948 'per_page' => $per_page,
842 949 'total_records' => $total_records,
843 - 'total_pages' => ceil($total_records / $per_page)
950 + // (int) so it serialises as 2, not 2.0.
951 + 'total_pages' => $per_page > 0 ? (int) ceil($total_records / $per_page) : 0
844 952 ],
845 953 'period' => $period
846 954 ]
847 955 ], 200);
@@ -889,14 +997,22 @@
889 997 * @param float $old_value Previous period value
890 998 * @param float $new_value Current period value
891 999 * @return float Percentage change
892 1000 */
893 - private function calculate_percentage_change(float $old_value, float $new_value): float {
1001 + private function calculate_percentage_change($old_value, float $new_value): ?float {
1002 + // No previous period at all (the 'all' range).
1003 + if (null === $old_value) {
1004 + return null;
1005 + }
1006 +
894 1007 if ((float) $old_value === 0.0) {
895 - return $new_value > 0 ? 100 : 0;
1008 + // Growth from nothing has no percentage. Reporting a flat 100%
1009 + // dressed it up as a measured change; null lets the UI say "new"
1010 + // (or say nothing) instead of inventing a number.
1011 + return $new_value > 0 ? null : 0.0;
896 1012 }
897 1013
898 - return round((($new_value - $old_value) / $old_value) * 100, 1);
1014 + return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1);
899 1015 }
900 1016
901 1017 /**
902 1018 * Get previous period data for comparison
@@ -913,8 +1029,14 @@
913 1029
914 1030 // Extract the interval from current date condition to calculate previous period
915 1031 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
916 1032
1033 + // No preceding window: report "not comparable" rather than querying a
1034 + // made-up one.
1035 + if (null === $previous_date_condition) {
1036 + return ['total_cost' => null, 'time_saved' => null];
1037 + }
1038 +
917 1039 // Prepare and execute query with proper parameter binding to prevent SQL injection
918 1040 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
919 1041 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
920 1042 $usage_data = $wpdb->get_results(
@@ -921,9 +1043,10 @@
921 1043 $wpdb->prepare("
922 1044 SELECT
923 1045 provider,
924 1046 action,
925 - tokens_used
1047 + tokens_used,
1048 + metadata
926 1049 FROM `{$table_name}`
927 1050 WHERE user_id = %d
928 1051 {$previous_date_condition}
929 1052 ", $user_id),
@@ -1091,9 +1214,9 @@
1091 1214 // Try specific model first, fallback to default
1092 1215 if ($model && isset(self::GEMINI_PRICING[$model])) {
1093 1216 return self::GEMINI_PRICING[$model];
1094 1217 }
1095 - return self::GEMINI_PRICING['gemini-2.5-flash'] ?? null;
1218 + return self::GEMINI_PRICING['gemini-3.5-flash'] ?? null;
1096 1219
1097 1220 case 'openrouter':
1098 1221 // Try specific model first, fallback to default
1099 1222 if ($model && isset(self::OPENROUTER_PRICING[$model])) {
@@ -1141,8 +1264,13 @@
1141 1264 $table_name = esc_sql($this->database->get_table('seo_scores'));
1142 1265
1143 1266 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1144 1267
1268 + // See get_previous_period_data(): no preceding window, no comparison.
1269 + if (null === $previous_date_condition) {
1270 + return ['content_optimized' => null, 'average_seo_score' => null];
1271 + }
1272 +
1145 1273 // Prepare and execute query with proper parameter binding to prevent SQL injection
1146 1274 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1147 1275 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
1148 1276 $result = $wpdb->get_row(
@@ -1189,12 +1317,18 @@
1189 1317
1190 1318 /**
1191 1319 * Convert current period condition to previous period condition
1192 1320 *
1321 + * Returns null when there is no preceding window to compare against.
1322 + * `all` produces an empty date condition, which used to fall through to a
1323 + * hardcoded 30–60 day fallback — so "all time" was compared against an
1324 + * arbitrary month and reported a large, meaningless increase. "No
1325 + * comparison" is now representable instead of being a parse failure.
1326 + *
1193 1327 * @param string $current_condition Current period SQL condition
1194 - * @return string Previous period SQL condition
1328 + * @return string|null Previous period SQL condition, or null when none exists
1195 1329 */
1196 - private function get_previous_period_condition(string $current_condition): string {
1330 + private function get_previous_period_condition(string $current_condition): ?string {
1197 1331 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1198 1332 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1199 1333 $interval = (int) $matches[1];
1200 1334 $unit = $matches[2];
@@ -1206,9 +1340,9 @@
1206 1340 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1207 1341 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1208 1342 }
1209 1343
1210 - // Fallback for unknown conditions
1211 - return "AND created_at >= DATE_SUB(NOW(), INTERVAL 60 DAY)
1212 - AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)";
1344 + // No interval means no window — 'all'. Comparing every record ever
1345 + // against a fabricated 30-day slice is not a trend.
1346 + return null;
1213 1347 }
1214 1348 }