PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.6.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.6.2
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Insights / Collector.php

Collector.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.6.2, at includes/Insights/Collector.php

297 lines 9.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\Insights;
4
5 use WPDeveloper\BetterDocs\Utils\Base;
6 use WPDeveloper\BetterDocs\Admin\ReportEmail;
7
8 /**
9 * Exit if accessed directly
10 */
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Product-usage analytics collector (Free tier).
17 *
18 * Hooks the `betterdocs_insights_data` filter exposed by {@see Insights::get_data()}
19 * and injects BetterDocs-specific metric groups (all prefixed `bd_`) into the daily
20 * wpinsight payload. Pro and the AI Chatbot add-on register their own collectors on
21 * the same filter — each plugin owns its own metrics, so a tier's keys appear in the
22 * payload only when that plugin is active.
23 *
24 * Design notes (see docs/insights-tracking.md for the full reference):
25 * - The client is WRITE-ONLY. It ships a current snapshot; wpinsight owns history and
26 * reconstructs true lifetime from the daily time series. The `totals` group is the
27 * sum of rows that currently exist locally — it legitimately drops if a user clears
28 * their analytics, which is why it is NOT called "lifetime".
29 * - The whole computed slice is cached in a 24h transient so the daily cron computes
30 * at most once per day; the cache is busted when settings are saved.
31 *
32 * MAINTENANCE CONVENTION: when a new user-facing feature/setting is introduced, the
33 * same PR must (1) add its flag/metric to the relevant group here and (2) update
34 * docs/insights-tracking.md.
35 *
36 * @since 4.5.4
37 */
38 class Collector extends Base {
39 /**
40 * Transient key for the cached metric slice.
41 */
42 const CACHE_KEY = 'betterdocs_insights_cache';
43
44 /**
45 * Free-tier boolean feature flags. Pro-gated flags (multiple_kb, advance_search,
46 * enable_content_restriction, enable_glossaries, enable_encyclopedia, …) are
47 * intentionally NOT here — they ship from the Pro collector.
48 *
49 * @var string[]
50 */
51 const FEATURE_FLAGS = [
52 'enable_toc',
53 'enable_sticky_toc',
54 'live_search',
55 'enable_faq_schema',
56 'enable_reporting',
57 'enable_estimated_reading_time',
58 'enable_navigation',
59 'enable_comment',
60 'enable_breadcrumb',
61 'enable_tags',
62 'enable_print_icon',
63 'enable_sidebar_cat_list',
64 'masonry_layout',
65 'nested_subcategory',
66 ];
67
68 public function __construct() {
69 add_filter( 'betterdocs_insights_data', [ $this, 'collect' ], 10, 1 );
70 // Recompute sooner than the 24h TTL when settings change.
71 add_action( 'betterdocs::settings::saved', [ $this, 'flush_cache' ] );
72 }
73
74 /**
75 * Filter callback: merge the cached `bd_*` groups into the tracking payload.
76 *
77 * @param array $body
78 * @return array
79 */
80 public function collect( $body ) {
81 $cached = get_transient( self::CACHE_KEY );
82 if ( ! is_array( $cached ) ) {
83 $cached = [
84 'bd_features' => $this->features(),
85 'bd_counts' => $this->content_counts(),
86 'bd_engagement' => $this->engagement(),
87 'bd_builders' => $this->builders(),
88 'bd_ai' => $this->ai(),
89 'bd_config' => $this->config_posture(),
90 ];
91 set_transient( self::CACHE_KEY, $cached, DAY_IN_SECONDS );
92 }
93
94 // wpinsight only persists base fields + a single `optional_data` field;
95 // nest the metric groups there (encoded to JSON in Insights::get_data()).
96 $body = (array) $body;
97 $opt = ( isset( $body['optional_data'] ) && is_array( $body['optional_data'] ) ) ? $body['optional_data'] : [];
98
99 $body['optional_data'] = array_merge( $opt, $cached );
100
101 return $body;
102 }
103
104 public function flush_cache() {
105 delete_transient( self::CACHE_KEY );
106 }
107
108 /**
109 * Booleanized map of which Free features are turned on.
110 */
111 protected function features() {
112 $settings = betterdocs()->settings;
113 $flags = [];
114 foreach ( self::FEATURE_FLAGS as $key ) {
115 $flags[ $key ] = (int) (bool) $settings->get( $key, false );
116 }
117 return $flags;
118 }
119
120 /**
121 * Content volume counts (all WP-cached or single indexed COUNTs).
122 */
123 protected function content_counts() {
124 $docs = (int) ( wp_count_posts( 'docs' )->publish ?? 0 );
125 $faqs = (int) ( wp_count_posts( 'betterdocs_faq' )->publish ?? 0 );
126
127 $doc_category = $this->term_count( 'doc_category' );
128
129 return [
130 'docs' => $docs,
131 'faqs' => $faqs,
132 'doc_category' => $doc_category,
133 'doc_tag' => $this->term_count( 'doc_tag' ),
134 'glossaries' => $this->term_count( 'glossaries' ),
135 'betterdocs_faq_category' => $this->term_count( 'betterdocs_faq_category' ),
136 'avg_docs_per_category' => $doc_category > 0 ? round( $docs / $doc_category, 2 ) : 0,
137 ];
138 }
139
140 /**
141 * Single indexed term COUNT, guarded against WP_Error / unregistered taxonomy.
142 */
143 protected function term_count( $taxonomy ) {
144 if ( ! taxonomy_exists( $taxonomy ) ) {
145 return 0;
146 }
147 $count = wp_count_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false ] );
148 return is_wp_error( $count ) ? 0 : (int) $count;
149 }
150
151 /**
152 * Runtime aggregates: current retained `totals` + rolling `last_7d` window.
153 */
154 protected function engagement() {
155 return [
156 'totals' => $this->engagement_totals(),
157 'last_7d' => $this->engagement_last_7d(),
158 ];
159 }
160
161 /**
162 * Current retained totals — SUM over whatever rows currently exist. NOT a
163 * protected lifetime; wpinsight reconstructs true lifetime from the daily series.
164 */
165 protected function engagement_totals() {
166 global $wpdb;
167
168 $analytics = $wpdb->get_row(
169 "SELECT SUM(impressions) AS views, SUM(unique_visit) AS unique_visit, SUM(happy) AS happy, SUM(sad) AS sad, SUM(normal) AS normal
170 FROM {$wpdb->prefix}betterdocs_analytics"
171 );
172
173 $search = $wpdb->get_row(
174 "SELECT SUM(count) AS search_found, SUM(not_found_count) AS search_not_found, COUNT(DISTINCT keyword_id) AS distinct_keywords
175 FROM {$wpdb->prefix}betterdocs_search_log"
176 );
177
178 return [
179 'views' => (int) ( $analytics->views ?? 0 ),
180 'unique_visit' => (int) ( $analytics->unique_visit ?? 0 ),
181 'happy' => (int) ( $analytics->happy ?? 0 ),
182 'sad' => (int) ( $analytics->sad ?? 0 ),
183 'normal' => (int) ( $analytics->normal ?? 0 ),
184 'search_found' => (int) ( $search->search_found ?? 0 ),
185 'search_not_found' => (int) ( $search->search_not_found ?? 0 ),
186 'distinct_keywords' => (int) ( $search->distinct_keywords ?? 0 ),
187 ];
188 }
189
190 /**
191 * Rolling last-7-day window, reusing ReportEmail's date-scoped aggregation.
192 */
193 protected function engagement_last_7d() {
194 /** @var ReportEmail $report */
195 $report = betterdocs()->container->get( ReportEmail::class );
196
197 $end = current_time( 'Y-m-d' );
198 $start = gmdate( 'Y-m-d', strtotime( '-7 days', current_time( 'timestamp' ) ) );
199
200 $views = $report->get_views( $start, $end );
201 $search = $report->get_search( $start, $end );
202
203 $views = isset( $views[0] ) ? $views[0] : null;
204 $search = isset( $search[0] ) ? $search[0] : null;
205
206 return [
207 'views' => (int) ( $views->views ?? 0 ),
208 'unique_visit' => (int) ( $views->unique_visit ?? 0 ),
209 'reactions' => (int) ( $views->reactions ?? 0 ),
210 'search_count' => (int) ( $search->search_count ?? 0 ),
211 'search_found' => (int) ( $search->search_found ?? 0 ),
212 'search_not_found' => (int) ( $search->search_not_found_count ?? 0 ),
213 'new_docs' => (int) $report->count_new_docs( $start, $end ),
214 ];
215 }
216
217 /**
218 * Which builder(s) the KB is authored with. Cheap heuristics — no post_content
219 * table scan; Gutenberg/shortcode are detected over the 20 most-recent docs.
220 */
221 protected function builders() {
222 global $wpdb;
223
224 $elementor = (int) (bool) $wpdb->get_var(
225 "SELECT 1 FROM {$wpdb->postmeta} pm
226 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
227 WHERE pm.meta_key = '_elementor_edit_mode' AND p.post_type = 'docs' LIMIT 1"
228 );
229
230 $gutenberg = 0;
231 $shortcode = 0;
232 $recent = get_posts(
233 [
234 'post_type' => 'docs',
235 'post_status' => 'publish',
236 'posts_per_page' => 20,
237 'orderby' => 'date',
238 'order' => 'DESC',
239 'no_found_rows' => true,
240 'suppress_filters' => true,
241 ]
242 );
243 foreach ( $recent as $post ) {
244 if ( ! $gutenberg && has_blocks( $post->post_content ) ) {
245 $gutenberg = 1;
246 }
247 if ( ! $shortcode && strpos( (string) $post->post_content, '[betterdocs' ) !== false ) {
248 $shortcode = 1;
249 }
250 if ( $gutenberg && $shortcode ) {
251 break;
252 }
253 }
254
255 return [
256 'elementor' => $elementor,
257 'gutenberg' => $gutenberg,
258 'shortcode' => $shortcode,
259 ];
260 }
261
262 /**
263 * AI adoption — booleans + non-secret model names. NEVER the API key value.
264 */
265 protected function ai() {
266 $settings = betterdocs()->settings;
267
268 return [
269 'write_with_ai' => (int) (bool) $settings->get( 'enable_write_with_ai', false ),
270 'article_summary' => (int) (bool) $settings->get( 'enable_article_summary', false ),
271 'chatbot' => (int) (bool) $settings->get( 'enable_ai_chatbot', false ),
272 'autowrite_key_set' => (int) ! empty( $settings->get( 'ai_autowrite_api_key', '' ) ),
273 'chatbot_key_set' => (int) ! empty( $settings->get( 'ai_chatbot_api_key', '' ) ),
274 'write_with_ai_model' => (string) $settings->get( 'write_with_ai_model', '' ),
275 'article_summary_model' => (string) $settings->get( 'article_summary_model', '' ),
276 // Per-feature usage counts (how many times each AI action ran). All keys
277 // always present (default 0); see Utils\AIUsage for the recording side.
278 'usage' => \WPDeveloper\BetterDocs\Utils\AIUsage::snapshot(),
279 ];
280 }
281
282 /**
283 * Non-sensitive configuration posture (no emails/URLs/free-text).
284 */
285 protected function config_posture() {
286 $settings = betterdocs()->settings;
287
288 return [
289 'layout' => (string) $settings->get( 'layout', '' ),
290 'permalink_structure' => (string) $settings->get( 'permalink_structure', '' ),
291 'reporting_frequency' => (string) $settings->get( 'reporting_frequency', '' ),
292 'posts_number' => (int) $settings->get( 'posts_number', 0 ),
293 'column_number' => (int) $settings->get( 'column_number', 0 ),
294 ];
295 }
296 }
297