PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 4.9.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 All 202 releases
betterdocs / includes / Insights / Collector.php

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

321 lines 11.3 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 'enable_ai_actions',
67 'enable_markdown_endpoint',
68 'enable_listen',
69 ];
70
71 public function __construct() {
72 add_filter( 'betterdocs_insights_data', [ $this, 'collect' ], 10, 1 );
73 // Recompute sooner than the 24h TTL when settings change.
74 add_action( 'betterdocs::settings::saved', [ $this, 'flush_cache' ] );
75 }
76
77 /**
78 * Filter callback: merge the cached `bd_*` groups into the tracking payload.
79 *
80 * @param array $body
81 * @return array
82 */
83 public function collect( $body ) {
84 $cached = get_transient( self::CACHE_KEY );
85 if ( ! is_array( $cached ) ) {
86 $cached = [
87 'bd_features' => $this->features(),
88 'bd_counts' => $this->content_counts(),
89 'bd_engagement' => $this->engagement(),
90 'bd_builders' => $this->builders(),
91 'bd_ai' => $this->ai(),
92 'bd_config' => $this->config_posture(),
93 ];
94 set_transient( self::CACHE_KEY, $cached, DAY_IN_SECONDS );
95 }
96
97 // wpinsight only persists base fields + a single `optional_data` field;
98 // nest the metric groups there (encoded to JSON in Insights::get_data()).
99 $body = (array) $body;
100 $opt = ( isset( $body['optional_data'] ) && is_array( $body['optional_data'] ) ) ? $body['optional_data'] : [];
101
102 $body['optional_data'] = array_merge( $opt, $cached );
103
104 return $body;
105 }
106
107 public function flush_cache() {
108 delete_transient( self::CACHE_KEY );
109 }
110
111 /**
112 * Booleanized map of which Free features are turned on.
113 */
114 protected function features() {
115 $settings = betterdocs()->settings;
116 $flags = [];
117 foreach ( self::FEATURE_FLAGS as $key ) {
118 $flags[ $key ] = (int) (bool) $settings->get( $key, false );
119 }
120 return $flags;
121 }
122
123 /**
124 * Content volume counts (all WP-cached or single indexed COUNTs).
125 */
126 protected function content_counts() {
127 $docs = (int) ( wp_count_posts( 'docs' )->publish ?? 0 );
128 $faqs = (int) ( wp_count_posts( 'betterdocs_faq' )->publish ?? 0 );
129
130 $doc_category = $this->term_count( 'doc_category' );
131
132 return [
133 'docs' => $docs,
134 'faqs' => $faqs,
135 'doc_category' => $doc_category,
136 'doc_tag' => $this->term_count( 'doc_tag' ),
137 'glossaries' => $this->term_count( 'glossaries' ),
138 'betterdocs_faq_category' => $this->term_count( 'betterdocs_faq_category' ),
139 'avg_docs_per_category' => $doc_category > 0 ? round( $docs / $doc_category, 2 ) : 0,
140 ];
141 }
142
143 /**
144 * Single indexed term COUNT, guarded against WP_Error / unregistered taxonomy.
145 */
146 protected function term_count( $taxonomy ) {
147 if ( ! taxonomy_exists( $taxonomy ) ) {
148 return 0;
149 }
150 $count = wp_count_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false ] );
151 return is_wp_error( $count ) ? 0 : (int) $count;
152 }
153
154 /**
155 * Runtime aggregates: current retained `totals` + rolling `last_7d` window.
156 */
157 protected function engagement() {
158 return [
159 'totals' => $this->engagement_totals(),
160 'last_7d' => $this->engagement_last_7d(),
161 ];
162 }
163
164 /**
165 * Current retained totals — SUM over whatever rows currently exist. NOT a
166 * protected lifetime; wpinsight reconstructs true lifetime from the daily series.
167 */
168 protected function engagement_totals() {
169 global $wpdb;
170
171 $analytics = $wpdb->get_row(
172 "SELECT SUM(impressions) AS views, SUM(unique_visit) AS unique_visit, SUM(happy) AS happy, SUM(sad) AS sad, SUM(normal) AS normal
173 FROM {$wpdb->prefix}betterdocs_analytics"
174 );
175
176 $search = $wpdb->get_row(
177 "SELECT SUM(count) AS search_found, SUM(not_found_count) AS search_not_found, COUNT(DISTINCT keyword_id) AS distinct_keywords
178 FROM {$wpdb->prefix}betterdocs_search_log"
179 );
180
181 return [
182 'views' => (int) ( $analytics->views ?? 0 ),
183 'unique_visit' => (int) ( $analytics->unique_visit ?? 0 ),
184 'happy' => (int) ( $analytics->happy ?? 0 ),
185 'sad' => (int) ( $analytics->sad ?? 0 ),
186 'normal' => (int) ( $analytics->normal ?? 0 ),
187 'search_found' => (int) ( $search->search_found ?? 0 ),
188 'search_not_found' => (int) ( $search->search_not_found ?? 0 ),
189 'distinct_keywords' => (int) ( $search->distinct_keywords ?? 0 ),
190 ];
191 }
192
193 /**
194 * Rolling last-7-day window, reusing ReportEmail's date-scoped aggregation.
195 */
196 protected function engagement_last_7d() {
197 /** @var ReportEmail $report */
198 $report = betterdocs()->container->get( ReportEmail::class );
199
200 $end = current_time( 'Y-m-d' );
201 $start = gmdate( 'Y-m-d', strtotime( '-7 days', current_time( 'timestamp' ) ) );
202
203 $views = $report->get_views( $start, $end );
204 $search = $report->get_search( $start, $end );
205
206 $views = isset( $views[0] ) ? $views[0] : null;
207 $search = isset( $search[0] ) ? $search[0] : null;
208
209 return [
210 'views' => (int) ( $views->views ?? 0 ),
211 'unique_visit' => (int) ( $views->unique_visit ?? 0 ),
212 'reactions' => (int) ( $views->reactions ?? 0 ),
213 'search_count' => (int) ( $search->search_count ?? 0 ),
214 'search_found' => (int) ( $search->search_found ?? 0 ),
215 'search_not_found' => (int) ( $search->search_not_found_count ?? 0 ),
216 'new_docs' => (int) $report->count_new_docs( $start, $end ),
217 ];
218 }
219
220 /**
221 * Which builder(s) the KB is authored with. Cheap heuristics — no post_content
222 * table scan; Gutenberg/shortcode are detected over the 20 most-recent docs.
223 */
224 protected function builders() {
225 global $wpdb;
226
227 $elementor = (int) (bool) $wpdb->get_var(
228 "SELECT 1 FROM {$wpdb->postmeta} pm
229 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
230 WHERE pm.meta_key = '_elementor_edit_mode' AND p.post_type = 'docs' LIMIT 1"
231 );
232
233 $gutenberg = 0;
234 $shortcode = 0;
235 $recent = get_posts(
236 [
237 'post_type' => 'docs',
238 'post_status' => 'publish',
239 'posts_per_page' => 20,
240 'orderby' => 'date',
241 'order' => 'DESC',
242 'no_found_rows' => true,
243 'suppress_filters' => true,
244 ]
245 );
246 foreach ( $recent as $post ) {
247 if ( ! $gutenberg && has_blocks( $post->post_content ) ) {
248 $gutenberg = 1;
249 }
250 if ( ! $shortcode && strpos( (string) $post->post_content, '[betterdocs' ) !== false ) {
251 $shortcode = 1;
252 }
253 if ( $gutenberg && $shortcode ) {
254 break;
255 }
256 }
257
258 return [
259 'elementor' => $elementor,
260 'gutenberg' => $gutenberg,
261 'shortcode' => $shortcode,
262 ];
263 }
264
265 /**
266 * AI adoption — booleans + non-secret model names. NEVER the API key value.
267 */
268 protected function ai() {
269 $settings = betterdocs()->settings;
270
271 // `show_glossary_suggestions` defaults to true and has no settings-UI field, so
272 // reporting it raw would mark every Free-only site as "using" a feature it can
273 // never run. Mirror the runtime gate in Core\WriteWithAI: the glossaries taxonomy
274 // is registered for Pro only, so the "Suggest glossaries" offer is reachable only
275 // when the glossary feature is on AND Pro is active. Do NOT "simplify" this back
276 // to a bare setting read. ($has_glossary_terms from the runtime gate is
277 // deliberately excluded — that is per-request content state, not config posture.)
278 $glossary_suggestions = (int) (
279 (bool) $settings->get( 'enable_glossaries', false )
280 && (bool) $settings->get( 'show_glossary_suggestions', true )
281 && betterdocs()->is_pro_active()
282 );
283
284 return [
285 'write_with_ai' => (int) (bool) $settings->get( 'enable_write_with_ai', false ),
286 'article_summary' => (int) (bool) $settings->get( 'enable_article_summary', false ),
287 'chatbot' => (int) (bool) $settings->get( 'enable_ai_chatbot', false ),
288 'autowrite_key_set' => (int) ! empty( $settings->get( 'ai_autowrite_api_key', '' ) ),
289 'chatbot_key_set' => (int) ! empty( $settings->get( 'ai_chatbot_api_key', '' ) ),
290 'write_with_ai_model' => (string) $settings->get( 'write_with_ai_model', '' ),
291 'article_summary_model' => (string) $settings->get( 'article_summary_model', '' ),
292 // AI adoption toggles introduced with the 4.6.3 AI surfaces.
293 'sample_docs' => (int) (bool) $settings->get( 'enable_ai_sample_docs', true ),
294 'docs_ai_suite' => (int) (bool) $settings->get( 'enable_docs_ai_suite', true ),
295 'glossary_suggestions' => $glossary_suggestions,
296 // Per-feature usage counts (how many times each AI action ran). All keys
297 // always present (default 0); see Utils\AIUsage for the recording side.
298 'usage' => \WPDeveloper\BetterDocs\Utils\AIUsage::snapshot(),
299 ];
300 }
301
302 /**
303 * Non-sensitive configuration posture (no emails/URLs/free-text).
304 */
305 protected function config_posture() {
306 $settings = betterdocs()->settings;
307
308 return [
309 'layout' => (string) $settings->get( 'layout', '' ),
310 'permalink_structure' => (string) $settings->get( 'permalink_structure', '' ),
311 'reporting_frequency' => (string) $settings->get( 'reporting_frequency', '' ),
312 'posts_number' => (int) $settings->get( 'posts_number', 0 ),
313 'column_number' => (int) $settings->get( 'column_number', 0 ),
314 // Analytics Overview (4.6.3) posture — key-set booleans only, never secrets.
315 'ga4_configured' => (int) ! empty( $settings->get( 'ga4_api_secret', '' ) ),
316 'geoip_configured' => (int) ! empty( $settings->get( 'maxmind_license_key', '' ) ),
317 'dark_mode' => (int) (bool) $settings->get( 'dark_mode', false ),
318 ];
319 }
320 }
321