PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.1
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 / Core / AnalyticsTracker.php

AnalyticsTracker.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.1, at includes/Core/AnalyticsTracker.php

212 lines 7.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\Core;
4
5 use WPDeveloper\BetterDocs\Utils\Base;
6
7 /**
8 * Lightweight, non-blocking doc-view collection for the Free Overview.
9 *
10 * The frontend tracker (assets/static/public/js/analytics-tracker.js) fires a
11 * sendBeacon after DOMContentLoaded on single docs; the REST ingest endpoint
12 * (REST/AnalyticsTracker) calls record_view() which increments the daily
13 * aggregate row in {prefix}betterdocs_analytics plus the per-post views meta.
14 *
15 * This replaces the Pro synchronous wp_head counter so collection lives in
16 * Free and a Pro upgrade is not empty. Pro adds richer collection in a later
17 * unit on top of the raw events table.
18 */
19 class AnalyticsTracker extends Base {
20 /**
21 * @var \WPDeveloper\BetterDocs\Core\Settings
22 */
23 protected $settings;
24
25 public function __construct( Settings $settings ) {
26 $this->settings = $settings;
27
28 add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_tracker' ] );
29 }
30
31 /**
32 * Enqueue the beacon on single docs only and hand it the post id + nonce.
33 */
34 public function enqueue_tracker() {
35 if ( ! is_singular( 'docs' ) ) {
36 return;
37 }
38
39 $post_id = get_the_ID();
40 if ( ! $post_id ) {
41 return;
42 }
43
44 $ga4_enabled = (bool) $this->settings->get( 'analytics_ga4', false );
45 $ga4_method = (string) $this->settings->get( 'ga4_method', 'datalayer' );
46 $measurement = sanitize_text_field( (string) $this->settings->get( 'ga4_measurement_id', '' ) );
47
48 betterdocs()->assets->enqueue( 'betterdocs-analytics-tracker', 'public/js/analytics-tracker.js', [], true );
49 betterdocs()->assets->localize(
50 'betterdocs-analytics-tracker',
51 'betterDocsTracker',
52 [
53 'rest_url' => rest_url( 'betterdocs/v1/analytics/view' ),
54 'nonce' => wp_create_nonce( 'wp_rest' ),
55 'post_id' => $post_id,
56 // GA4 forwarding config. `method` selects how events are sent
57 // (datalayer | gtag | mp); the Measurement Protocol api_secret is
58 // intentionally NEVER exposed here — it is read server-side only.
59 'ga4' => [
60 'enabled' => $ga4_enabled,
61 'method' => $ga4_method,
62 'measurement_id' => ( 'gtag' === $ga4_method ) ? $measurement : ''
63 ]
64 ]
65 );
66
67 // gtag mode: load Google's gtag.js on doc pages and initialize the property
68 // so the tracker's gtag('event', …) calls reach GA4 without external GTM.
69 if ( $ga4_enabled && 'gtag' === $ga4_method && '' !== $measurement ) {
70 wp_enqueue_script(
71 'betterdocs-gtag',
72 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $measurement ),
73 [],
74 null,
75 false
76 );
77 wp_add_inline_script(
78 'betterdocs-gtag',
79 "window.dataLayer = window.dataLayer || [];\n"
80 . "function gtag(){dataLayer.push(arguments);}\n"
81 . "gtag('js', new Date());\n"
82 . "gtag('config', '" . esc_js( $measurement ) . "');",
83 'after'
84 );
85 }
86 }
87
88 /**
89 * Increment the daily aggregate for a doc view. Write-only, fast.
90 *
91 * @param int $post_id Doc post id.
92 * @param int $unique_hint 1 if the client reports this is a first view in the browser.
93 * @return bool True when a view was recorded.
94 */
95 public function record_view( $post_id, $unique_hint = 0 ) {
96 global $wpdb;
97
98 $post_id = (int) $post_id;
99 if ( ! $post_id || ! $this->is_eligible_visits() ) {
100 return false;
101 }
102
103 $post = get_post( $post_id );
104 if ( ! $post || $post->post_type !== 'docs' || $post->post_status !== 'publish' ) {
105 return false;
106 }
107
108 $table = $wpdb->prefix . 'betterdocs_analytics';
109 $today = gmdate( 'Y-m-d' );
110 $unique_enabled = $this->settings->get( 'unique_visitor_count' ) != false;
111 $unique_inc = ( $unique_enabled && (int) $unique_hint === 1 ) ? 1 : 0;
112
113 $row_id = $wpdb->get_var(
114 $wpdb->prepare(
115 "SELECT id FROM {$wpdb->prefix}betterdocs_analytics WHERE post_id = %d AND created_at = %s",
116 $post_id,
117 $today
118 )
119 );
120
121 if ( $row_id ) {
122 // Atomic increments avoid a read-modify-write race between concurrent beacons.
123 $wpdb->query(
124 $wpdb->prepare(
125 "UPDATE {$wpdb->prefix}betterdocs_analytics SET impressions = impressions + 1, unique_visit = unique_visit + %d WHERE id = %d",
126 $unique_inc,
127 $row_id
128 )
129 );
130 } else {
131 // ON DUPLICATE KEY UPDATE makes the first-view-of-day insert idempotent:
132 // two concurrent beacons that both miss the SELECT above collapse into a
133 // single row (incrementing) instead of racing to create duplicates,
134 // wherever the (post_id, created_at) UNIQUE key is present.
135 $wpdb->query(
136 $wpdb->prepare(
137 "INSERT INTO {$wpdb->prefix}betterdocs_analytics ( post_id, impressions, unique_visit, created_at ) VALUES ( %d, %d, %d, %s )
138 ON DUPLICATE KEY UPDATE impressions = impressions + 1, unique_visit = unique_visit + VALUES( unique_visit )",
139 $post_id,
140 1,
141 $unique_inc,
142 $today
143 )
144 );
145 }
146
147 $views = (int) get_post_meta( $post_id, '_betterdocs_meta_views', true );
148 update_post_meta( $post_id, '_betterdocs_meta_views', $views + 1 );
149
150 return true;
151 }
152
153 /**
154 * Whether the current request should be counted, per analytics_from +
155 * exclude_bot_analytics settings. Ported from the legacy Pro counter and
156 * corrected to use get_current_user_id().
157 */
158 /**
159 * Public eligibility check (analytics_from + bot exclusion) for callers like
160 * the scroll-completion path that don't go through record_view().
161 */
162 public function is_eligible() {
163 return $this->is_eligible_visits();
164 }
165
166 protected function is_eligible_visits() {
167 $should_count = false;
168 $analytics_from = $this->settings->get( 'analytics_from', 'everyone' );
169 $user_id = get_current_user_id();
170
171 switch ( $analytics_from ) {
172 case 'everyone':
173 $should_count = true;
174 break;
175 case 'guests':
176 if ( $user_id === 0 ) {
177 $should_count = true;
178 }
179 break;
180 case 'registered_users':
181 if ( $user_id > 0 ) {
182 $should_count = true;
183 }
184 break;
185 }
186
187 if ( ! $should_count ) {
188 return false;
189 }
190
191 if ( $this->settings->get( 'exclude_bot_analytics', true ) == 1 ) {
192 $bots = [ 'google', 'msnbot', 'ia_archiver', 'lycos', 'jeeves', 'scooter', 'fast-webcrawler', 'slurp@inktomi', 'turnitinbot', 'technorati', 'yahoo', 'findexa', 'findlinks', 'gaisbo', 'zyborg', 'surveybot', 'bloglines', 'blogsearch', 'pubsub', 'syndic8', 'userland', 'gigabot', 'become.com', 'baiduspider', '360spider', 'spider', 'sosospider', 'yandex',
193 // AI agents / crawlers — mirror of the Pro AiTrafficCollector catalog so a
194 // JS-capable or spoofed AI user-agent is never double-counted as a human
195 // view (Pro records it as an AI fetch server-side; the human path is a JS
196 // beacon). Free must not depend on Pro, so the tokens are duplicated here.
197 // stripos is case-insensitive, so lowercase is sufficient.
198 'gptbot', 'chatgpt-user', 'oai-searchbot', 'claudebot', 'anthropic-ai', 'claude-user', 'claude-web', 'claude-searchbot', 'perplexitybot', 'perplexity-user', 'google-extended', 'googleother', 'applebot-extended', 'cursor/', 'copilot', 'meta-externalagent', 'meta-externalfetcher', 'facebookbot', 'youbot', 'ccbot', 'bytespider', 'duckassistbot' ];
199 $useragent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
200 if ( $useragent !== '' ) {
201 foreach ( $bots as $lookfor ) {
202 if ( false !== stripos( $useragent, $lookfor ) ) {
203 return false;
204 }
205 }
206 }
207 }
208
209 return $should_count;
210 }
211 }
212