PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / 1.4.0
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking v1.4.0
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / admin / analytics.php

analytics.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking 1.4.0, at admin/analytics.php

817 lines 29.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SureCookie Analytics - BSF Analytics Integration
4 *
5 * @package SureCookie\Admin
6 * @since 0.0.1-beta.1
7 */
8
9 namespace SureCookie\Admin;
10
11 use SureCookie\Inc\Functions\Helper;
12 use SureCookie\Inc\Functions\Settings;
13 use SureCookie\Inc\Modules\AssistedScan\Telemetry as AssistedScanTelemetry;
14 use SureCookie\Inc\Modules\Auth\Controller as AuthController;
15 use SureCookie\Inc\Modules\SiteScanner\SaasClient;
16 use SureCookie\Inc\Modules\SiteScanner\Utils as ScannerUtils;
17 use SureCookie\Inc\Traits\GetInstance;
18
19 defined( 'ABSPATH' ) || exit;
20
21 /**
22 * Analytics class.
23 *
24 * Handles BSF Analytics integration: event tracking, stats payload, and KPI collection.
25 *
26 * @since 0.0.1-beta.1
27 */
28 class Analytics {
29 use GetInstance;
30
31 /**
32 * Analytics events schema version. Bump whenever an existing event's shape
33 * (value or properties) changes, and add a `<new-version> => [ 'event' ]`
34 * entry to RESHAPED_EVENTS_BY_VERSION so the installed base re-emits it once.
35 * v1 = original schema.
36 *
37 * @since 1.2.0
38 */
39 private const ANALYTICS_EVENTS_VERSION = 2;
40
41 /**
42 * Events reshaped in each schema version, keyed by the version that introduced
43 * the change. The migration re-emits the events for every version a site is
44 * newly crossing, so each entry fires exactly once per site and older entries
45 * are never replayed - nothing here needs to be removed on a future bump.
46 *
47 * @since 1.2.0
48 */
49 private const RESHAPED_EVENTS_BY_VERSION = [
50 2 => [ 'banner_configured' ],
51 ];
52
53 /**
54 * Public-content volume bands keyed by their exclusive upper bound. The first
55 * bound a count falls under wins, so a site reports one band and never the
56 * wider ones above it; 1000 and up falls through to `greater_than_1000`.
57 *
58 * @since x.x.x
59 */
60 private const CONTENT_VOLUME_BUCKETS = [
61 10 => 'less_than_10',
62 50 => 'less_than_50',
63 100 => 'less_than_100',
64 150 => 'less_than_150',
65 200 => 'less_than_200',
66 500 => 'less_than_500',
67 1000 => 'less_than_1000',
68 ];
69
70 /**
71 * Events tracker.
72 *
73 * @var \BSF_Analytics_Events|null
74 */
75 private static $events_tracker = null;
76
77 /**
78 * Plugin version recorded before this request, captured on `plugins_loaded`
79 * (before Maintenance updates `surecookie_saved_version` on `admin_init`).
80 * Lets detect_state_events() identify upgrades even though it now runs on
81 * `admin_init`.
82 *
83 * @since 1.2.0
84 * @var string
85 */
86 private $pre_upgrade_version = '';
87
88 /**
89 * Constructor.
90 *
91 * @since 0.0.1-beta.1
92 */
93 public function __construct() {
94 // Stats payload filter.
95 add_filter( 'bsf_core_stats', [ $this, 'add_analytics_data' ] );
96
97 // Only run analytics in admin context.
98 if ( ! is_admin() ) {
99 return;
100 }
101
102 // Load Astra Notices for opt-in UI.
103 if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
104 require_once SURECOOKIE_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
105 }
106
107 add_filter(
108 'uds_survey_allowed_screens',
109 static function () {
110 return [ 'plugins' ];
111 }
112 );
113
114 // Load BSF Analytics library.
115 if ( ! class_exists( 'BSF_Analytics_Loader' ) ) {
116 require_once SURECOOKIE_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-loader.php';
117 }
118
119 if ( ! class_exists( 'BSF_Analytics_Loader' ) ) {
120 return;
121 }
122
123 /** @var \BSF_Analytics_Loader $loader */
124 $loader = \BSF_Analytics_Loader::get_instance();
125 // Upstream docblock types $data as string, but the implementation pushes it onto an array of entity configs - see class-bsf-analytics-loader.php::set_entity().
126
127 $deactivation_surveys = [
128 [
129 'id' => 'deactivation-survey-surecookie',
130 'popup_logo' => SURECOOKIE_URL . 'assets/images/surecookie--brand-colored.svg',
131 'plugin_slug' => 'surecookie',
132 'popup_title' => 'Quick Feedback',
133 'support_url' => Helper::get_marketing_link( 'contact/', 'deactivation_survey_support' ),
134 'popup_description' => 'If you have a moment, please share why you are deactivating SureCookie:',
135 'show_on_screens' => [ 'plugins', 'plugins-network' ],
136 'plugin_version' => SURECOOKIE_VERSION,
137 ],
138 ];
139
140 // Capture Pro deactivations too when Pro is active.
141 if ( defined( 'SURECOOKIE_PRO_VERSION' ) ) {
142 $deactivation_surveys[] = [
143 'id' => 'deactivation-survey-surecookie-pro',
144 'popup_logo' => SURECOOKIE_URL . 'assets/images/surecookie--brand-colored.svg',
145 'plugin_slug' => 'surecookie-pro',
146 'popup_title' => 'Quick Feedback',
147 'support_url' => Helper::get_marketing_link( 'contact/', 'deactivation_survey_support' ),
148 'popup_description' => 'If you have a moment, please share why you are deactivating SureCookie premium version:',
149 'show_on_screens' => [ 'plugins', 'plugins-network' ],
150 'plugin_version' => SURECOOKIE_PRO_VERSION,
151 ];
152 }
153
154 $loader->set_entity(
155 [ // @phpstan-ignore argument.type
156 'surecookie' => [
157 'product_name' => 'SureCookie',
158 'path' => SURECOOKIE_DIR . 'inc/lib/bsf-analytics',
159 'author' => 'Brainstorm Force',
160 'time_to_display' => '+24 hours',
161 // IMPORTANT: Must be array of arrays - library iterates and passes each to show_feedback_form().
162 'deactivation_survey' => apply_filters( 'surecookie_deactivation_survey_data', $deactivation_surveys ),
163 ],
164 ]
165 );
166
167 // Detect version change vs `surecookie_saved_version` (owned by Maintenance), before
168 // the daily throttle gate so an update is never missed. Note: a frontend-first upgrade
169 // or BSF_Analytics_Events load failure may skip `plugin_updated` - accepted tradeoff.
170 $saved_version = get_option( 'surecookie_saved_version', '' );
171 $this->pre_upgrade_version = is_string( $saved_version ) ? $saved_version : '';
172 if ( ! empty( $saved_version ) && $saved_version !== SURECOOKIE_VERSION ) {
173 delete_transient( 'surecookie_state_events_checked' );
174 }
175
176 // One-time re-emit of reshaped events for the installed base (runs before
177 // the throttle gate so it can bust the transient and re-queue this load).
178 $this->maybe_migrate_events_schema();
179
180 // State-based events, throttled once per day. Deferred to `admin_init` (not inline on
181 // `plugins_loaded`) so add-ons registering a `surecookie_detect_state_events` listener
182 // on `init` are attached before detection fires its extension hook.
183 add_action( 'admin_init', [ $this, 'maybe_detect_state_events' ] );
184 }
185
186 // ============================================
187 // Event Tracker
188 // ============================================
189
190 /**
191 * Get shared event tracker instance.
192 *
193 * @since 0.0.1-beta.1
194 * @return \BSF_Analytics_Events|null
195 */
196 public static function events() {
197 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
198 require_once SURECOOKIE_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-events.php';
199 }
200
201 if ( self::$events_tracker === null ) {
202 self::$events_tracker = new \BSF_Analytics_Events( 'surecookie' );
203 }
204
205 return self::$events_tracker;
206 }
207
208 // ============================================
209 // Stats Payload
210 // ============================================
211
212 /**
213 * Add SureCookie analytics data to the BSF core stats payload.
214 *
215 * @param array<string, mixed> $stats_data Existing stats data.
216 * @return array<string, mixed> Modified stats data.
217 * @since 0.0.1-beta.1
218 */
219 public function add_analytics_data( $stats_data ) {
220 $events = self::events();
221
222 $stats_data['plugin_data']['surecookie'] = [
223 'free_version' => SURECOOKIE_VERSION,
224 'site_language' => get_locale(),
225
226 // One-time events (flushed from pending queue).
227 'events_record' => $events ? $events->flush_pending() : [],
228
229 // Daily KPIs (last 2 days).
230 'kpi_records' => $this->get_kpi_tracking_data(),
231
232 // Get some total important data.
233 'total_scans' => $this->get_total_scans(),
234 'total_logs' => (int) Settings::get( 'total_logs' ),
235 ];
236
237 return $stats_data;
238 }
239
240 // ============================================
241 // State Event Detection
242 // ============================================
243
244 /**
245 * Run state-event detection once per day (throttle gate).
246 *
247 * Hooked to `admin_init` so add-on listeners registered during component load
248 * (on `init`) are attached before detect_state_events() fires its hook.
249 *
250 * @since 1.2.0
251 * @return void
252 */
253 public function maybe_detect_state_events(): void {
254 if ( get_transient( 'surecookie_state_events_checked' ) === false ) {
255 $this->detect_state_events();
256 }
257 }
258
259 /**
260 * Re-emit reshaped events once per analytics schema-version bump.
261 *
262 * When the stored schema version is older than the code's, the affected
263 * events are removed from the pushed dedup list and the throttle transient is
264 * cleared so detect_state_events() re-queues them with their new shape on this
265 * same load. Idempotent: a no-op once the stored version matches.
266 *
267 * @since 1.2.0
268 * @return void
269 */
270 private function maybe_migrate_events_schema(): void {
271 $stored = (int) get_option( 'surecookie_analytics_events_version', 0 );
272
273 // Collect events reshaped in every version newer than the stored one. Already-migrated
274 // versions are skipped, so an event never replays - nothing to prune on a future bump.
275 $to_flush = [];
276 foreach ( self::RESHAPED_EVENTS_BY_VERSION as $version => $event_names ) {
277 if ( $version > $stored ) {
278 $to_flush = array_merge( $to_flush, $event_names );
279 }
280 }
281
282 if ( $to_flush === [] ) {
283 return; // Already up to date - nothing to re-emit.
284 }
285
286 $events = self::events();
287
288 if ( $events === null ) {
289 // Tracker class not loaded yet - retry next load without bumping.
290 return;
291 }
292
293 // flush_pushed() with names re-opens only those events; the early return
294 // above guarantees we never pass [] (which would clear ALL dedup).
295 $events->flush_pushed( array_values( array_unique( $to_flush ) ) );
296 delete_transient( 'surecookie_state_events_checked' );
297
298 update_option( 'surecookie_analytics_events_version', self::ANALYTICS_EVENTS_VERSION, false );
299 }
300
301 /**
302 * Detect and queue state-based events on admin page load.
303 *
304 * Runs on every admin load but throttled by a daily transient.
305 * BSF_Analytics_Events dedup prevents duplicate tracking.
306 *
307 * @since 0.0.1-beta.1
308 * @return void
309 */
310 private function detect_state_events(): void {
311 $events = self::events();
312
313 if ( $events === null ) {
314 // BSF_Analytics_Events class not loaded - do NOT set transient; retry next load.
315 return;
316 }
317
318 // Class is available - set throttle transient so we don't re-run for 24h.
319 set_transient( 'surecookie_state_events_checked', 1, DAY_IN_SECONDS );
320
321 // ── 1. plugin_activated ──────────────────────────────────────────
322 $install_time = get_option( 'surecookie_usage_installed_time', 0 );
323 if ( ! $install_time ) {
324 update_option( 'surecookie_usage_installed_time', time(), false );
325 }
326
327 $bsf_referrers = get_option( 'bsf_product_referers', [] );
328 $source = ! empty( $bsf_referrers['surecookie'] )
329 ? sanitize_text_field( $bsf_referrers['surecookie'] )
330 : 'self';
331
332 // Emit a fixed flag as the event_value, carry the version in properties['version'].
333 // Keeps the KPI breakdown one row per event, not one per release (@since 1.2.4).
334 $events->track(
335 'plugin_activated',
336 'activated',
337 [
338 'version' => SURECOOKIE_VERSION,
339 'source' => $source,
340 ]
341 );
342
343 // ── 2. plugin_updated ────────────────────────────────────────────
344 // Use the version captured on `plugins_loaded` (before Maintenance::init updates
345 // `surecookie_saved_version` on `admin_init`), preserving the pre-upgrade version.
346 $saved_version = $this->pre_upgrade_version;
347 if ( $saved_version !== SURECOOKIE_VERSION && ! empty( $saved_version ) ) {
348 $events->flush_pushed( [ 'plugin_updated' ] );
349 $events->track(
350 'plugin_updated',
351 'updated',
352 [
353 'from_version' => $saved_version,
354 'to_version' => SURECOOKIE_VERSION,
355 ]
356 );
357 }
358
359 // ── user_active_version (recurring version heartbeat) ────────────
360 // Reports the version each ACTIVE site is on. $force re-queues it every cycle so the
361 // latest version wins and it survives the one-time dedup (like plugin_updated). The
362 // one event whose value stays the version by design - the KPI breakdown then shows
363 // active installs per release (@since 1.2.4).
364 $events->track( 'user_active_version', SURECOOKIE_VERSION, [], true );
365
366 // ── 3. onboarding_completed ──────────────────────────────────────
367 if ( get_option( SURECOOKIE_ONBOARDING_COMPLETED_OPTION, false ) ) {
368 $events->track( 'onboarding_completed', 'completed', [ 'version' => SURECOOKIE_VERSION ] );
369 }
370
371 // ── 4. onboarding_skipped ────────────────────────────────────────
372 // Fires when onboarding has not been completed after 3+ days since install.
373 $days_since_install = $this->get_days_since_install();
374 if ( ! get_option( SURECOOKIE_ONBOARDING_COMPLETED_OPTION, false ) && $days_since_install >= 3 ) {
375 $events->track(
376 'onboarding_skipped',
377 'skipped',
378 [
379 'version' => SURECOOKIE_VERSION,
380 'days_since_install' => (string) $days_since_install,
381 ]
382 );
383 }
384
385 // ── 5. banner_configured ─────────────────────────────────────────
386 // Fires when admin settings have been saved at least once. Carries the compliance
387 // law (always set; default GDPR) to capture the GDPR/CCPA/LGPD distribution here.
388 if ( get_option( SURECOOKIE_SETTINGS_OPTION ) !== false ) {
389 $compliance_law = Settings::get( 'compliance_law' );
390 $law_name = is_array( $compliance_law ) ? ( $compliance_law['name'] ?? '' ) : '';
391 $events->track(
392 'banner_configured',
393 'configured',
394 [
395 'version' => SURECOOKIE_VERSION,
396 'days_since_install' => (string) $days_since_install,
397 'compliance_law' => (string) $law_name,
398 ]
399 );
400 }
401
402 // ── 6. script_blocking_disabled ──────────────────────────────────
403 // `blocking_enabled` defaults to true, so an "enabled" event fires for nearly every
404 // site. The meaningful signal is the rare cohort that turns blocking OFF.
405 if ( ! (bool) Settings::get( 'blocking_enabled' ) ) {
406 $events->track( 'script_blocking_disabled', 'disabled' );
407 }
408
409 // ── 7. first_scan_started ────────────────────────────────────────
410 if ( get_option( 'surecookie_first_scan_started_flag', false ) ) {
411 $pages_count = (int) get_option( 'surecookie_first_scan_pages_count', 0 );
412 $events->track(
413 'first_scan_started',
414 'started',
415 [
416 'version' => SURECOOKIE_VERSION,
417 'pages_count' => (string) $pages_count,
418 ]
419 );
420 }
421
422 // ── 8. first_scan_completed (ACTIVATION EVENT) ───────────────────
423 if ( get_option( 'surecookie_first_scan_completed_flag', false ) ) {
424 $pages_scanned = (int) get_option( 'surecookie_first_scan_pages_scanned', 0 );
425 $events->track(
426 'first_scan_completed',
427 'completed',
428 [
429 'version' => SURECOOKIE_VERSION,
430 'days_since_install' => (string) $days_since_install,
431 'pages_scanned' => (string) $pages_scanned,
432 ]
433 );
434 }
435
436 // ── 9. first_consent_recorded ────────────────────────────────────
437 // Fires when the first real visitor consent is stored in the database.
438 $first_consent = Settings::get( 'total_logs' );
439 if ( $first_consent > 0 ) {
440 $events->track(
441 'first_consent_recorded',
442 'recorded',
443 [
444 'version' => SURECOOKIE_VERSION,
445 'days_since_install' => (string) $days_since_install,
446 ]
447 );
448 }
449
450 // ── 10. consent_logging_enabled ──────────────────────────────────
451 if ( (bool) Settings::get( 'consent_logging_enabled' ) ) {
452 $events->track( 'consent_logging_enabled', 'enabled' );
453 }
454
455 // ── 11. first_custom_cookie_added ────────────────────────────────
456 // Fires when the user has manually added at least one custom cookie.
457 $custom_cookies = Settings::get( 'custom_cookies' );
458 if ( ! empty( $custom_cookies ) && is_array( $custom_cookies ) ) {
459 $events->track(
460 'first_custom_cookie_added',
461 'added',
462 [ 'count' => (string) count( $custom_cookies ) ]
463 );
464 }
465
466 // ── 12. upgrade_banner_dismissed ─────────────────────────────────
467 // Fires once the user dismissed the pro upgrade nudge at least once. Key off the
468 // count: the nudge's `display` flag stays true until the 2nd dismissal.
469 $nudges = get_option( SURECOOKIE_NUDGES, [] );
470 if ( ! empty( $nudges['upgrade_banner']['count'] ) ) {
471 $events->track(
472 'upgrade_banner_dismissed',
473 'dismissed',
474 [ 'count' => (string) $nudges['upgrade_banner']['count'] ]
475 );
476 }
477
478 // ── 13. consent_log_report_nudge_dismissed ───────────────────────
479 // Symmetric to the upgrade nudge - the second registered nudge type.
480 if ( ! empty( $nudges['consent_log_report']['count'] ) ) {
481 $events->track(
482 'consent_log_report_nudge_dismissed',
483 'dismissed',
484 [ 'count' => (string) $nudges['consent_log_report']['count'] ]
485 );
486 }
487
488 // ── 14. google_consent_mode_enabled ──────────────────────────────
489 if ( (bool) Settings::get( 'gcm_enabled' ) ) {
490 $events->track( 'google_consent_mode_enabled', 'enabled' );
491 }
492
493 // ── 15. account_connected ────────────────────────────────────────
494 // Fires once the site has linked its SureCookie SaaS account (required for
495 // cloud scanning). `tier` segments this near-universal event by plan.
496 if ( AuthController::get_instance()->get_account_ref() !== null ) {
497 $events->track(
498 'account_connected',
499 'connected',
500 [ 'tier' => (string) ScannerUtils::get_plan() ]
501 );
502 }
503
504 // ── 16. auto_scanning_enabled ────────────────────────────────────
505 if ( (bool) Settings::get( 'auto_scan_enabled' ) ) {
506 $events->track(
507 'auto_scanning_enabled',
508 'enabled',
509 [ 'frequency' => (string) Settings::get( 'auto_scan_frequency' ) ]
510 );
511 }
512
513 // ── 17. first_auto_scan_started ──────────────────────────────────
514 // Fires once the scheduler has actually run an automatic scan (the
515 // realized-value milestone - vs auto_scanning_enabled, which is intent).
516 if ( get_option( 'surecookie_first_auto_scan_started_flag', false ) ) {
517 $events->track(
518 'first_auto_scan_started',
519 'started',
520 [
521 'version' => SURECOOKIE_VERSION,
522 'frequency' => (string) get_option( 'surecookie_first_auto_scan_frequency', '' ),
523 ]
524 );
525 }
526
527 // ── 18. mcp_server_enabled ───────────────────────────────────────
528 if ( (bool) Settings::get( 'enable_mcp' ) ) {
529 $events->track( 'mcp_server_enabled', 'enabled' );
530 }
531
532 // ── 19. opt_out_model_enabled ────────────────────────────────────
533 if ( Settings::get( 'consent_model' ) === 'opt-out' ) {
534 $events->track( 'opt_out_model_enabled', 'opt-out' );
535 }
536
537 // ── 20. cookie_policy_page_configured ────────────────────────────
538 // Emit a fixed 'configured' flag, never the page ID. The value only needs to signal
539 // a policy page is assigned; the raw ID made every site a distinct event_value,
540 // flooding the KPI breakdown with one row per ID (@since 1.2.4).
541 if ( (int) Settings::get( 'cookie_policy_page_id' ) > 0 ) {
542 $events->track( 'cookie_policy_page_configured', 'configured' );
543 }
544
545 // ── 21. custom_css_applied ───────────────────────────────────────
546 if ( trim( (string) Settings::get( 'custom_css' ) ) !== '' ) {
547 $events->track( 'custom_css_applied', 'applied' );
548 }
549
550 // ── 22. reconsent_button_configured ──────────────────────────────
551 // Keyed off the assigned menu - the button label is always defaulted.
552 if ( (string) Settings::get( 'reconsent_menu_id' ) !== '' ) {
553 $events->track( 'reconsent_button_configured', 'configured' );
554 }
555
556 // ── 23. banner_customized ────────────────────────────────────────
557 // Fires when the banner's visual configuration diverges from defaults.
558 $banner_signals = [];
559 if ( (string) Settings::get( 'banner_logo' ) !== '' ) {
560 $banner_signals[] = 'logo';
561 }
562 if ( Settings::get( 'banner_animation' ) !== 'fade' ) {
563 $banner_signals[] = 'animation';
564 }
565 if ( (bool) Settings::get( 'banner_overlay_enabled' ) ) {
566 $banner_signals[] = 'overlay';
567 }
568 // Compared against the schema default (not a literal) so a default
569 // change never silently breaks the signal. Legacy sites pinned to the
570 // old full-width look by the upgrade migration will report this
571 // signal - accurate, since they now diverge from the shipped default.
572 if ( Settings::get( 'notice_type' ) !== ( Settings::get_settings_defaults()['notice_type'] ?? '' ) ) {
573 $banner_signals[] = 'notice_type';
574 }
575 if ( ! empty( $banner_signals ) ) {
576 $events->track(
577 'banner_customized',
578 'customized',
579 [ 'signals' => implode( ',', $banner_signals ) ]
580 );
581 }
582
583 // ── 24. cloud_scan_blocked ───────────────────────────────────────
584 // The site's host served our scanner a challenge instead of the page. Across the
585 // installed base this measures how badly scanner reachability needs fixing - and
586 // it's why Assisted Scan exists, so it's tracked whether or not the fallback was used.
587 if ( get_option( SaasClient::BLOCKED_FLAG_OPTION, false ) ) {
588 $events->track(
589 'cloud_scan_blocked',
590 'blocked',
591 [
592 'version' => SURECOOKIE_VERSION,
593 'days_since_install' => (string) $days_since_install,
594 ]
595 );
596 }
597
598 // ── 25. assisted_scan_started ────────────────────────────────────
599 // Reached for the browser-collected fallback at least once.
600 if ( get_option( AssistedScanTelemetry::STARTED_FLAG, false ) ) {
601 $events->track(
602 'assisted_scan_started',
603 'started',
604 [
605 'version' => SURECOOKIE_VERSION,
606 'days_since_install' => (string) $days_since_install,
607 ]
608 );
609 }
610
611 // ── 26. assisted_scan_completed ──────────────────────────────────
612 // The recovery actually worked. `registered` separates the failure severities (an
613 // unregistered site could never scan; a registered one merely had a scan blocked).
614 // `adblock_suspected` flags known-incomplete results so they don't inflate success.
615 if ( get_option( AssistedScanTelemetry::COMPLETED_FLAG, false ) ) {
616 $stats = get_option( AssistedScanTelemetry::STATS_OPTION, [] );
617 $stats = is_array( $stats ) ? $stats : [];
618
619 $events->track(
620 'assisted_scan_completed',
621 'completed',
622 [
623 'version' => SURECOOKIE_VERSION,
624 'days_since_install' => (string) $days_since_install,
625 'pages_walked' => (string) (int) ( $stats['pages'] ?? 0 ),
626 'cookies_found' => (string) (int) ( $stats['cookies'] ?? 0 ),
627 'services_found' => (string) (int) ( $stats['services'] ?? 0 ),
628 'adblock_suspected' => empty( $stats['adblock_suspected'] ) ? 'no' : 'yes',
629 'registered' => empty( $stats['registered'] ) ? 'no' : 'yes',
630 ]
631 );
632 }
633
634 // ── 27. assisted_scan_abandoned ──────────────────────────────────
635 // A walk that stopped reporting and had to be closed out by the rescue pass, not
636 // finished by the browser. The cohort to watch if the walk asks too much of people.
637 if ( get_option( AssistedScanTelemetry::ABANDONED_FLAG, false ) ) {
638 $events->track(
639 'assisted_scan_abandoned',
640 'abandoned',
641 [ 'version' => SURECOOKIE_VERSION ]
642 );
643 }
644
645 // ── 28. known_services_installed ─────────────────────────────────
646 // At least one catalog service is actively managed, i.e. the admin declared a
647 // blocked embed's cookies rather than leaving the policy incomplete. `count`
648 // separates "tried one" from "curated the site", captured at first adoption.
649 $installed_services = get_option( SURECOOKIE_INSTALLED_SERVICES_OPTION, [] );
650 $installed_slugs = is_array( $installed_services ) && is_array( $installed_services['installed'] ?? null )
651 ? $installed_services['installed']
652 : [];
653 if ( $installed_slugs !== [] ) {
654 $events->track(
655 'known_services_installed',
656 'installed',
657 [
658 'version' => SURECOOKIE_VERSION,
659 'days_since_install' => (string) $days_since_install,
660 'count' => (string) count( $installed_slugs ),
661 ]
662 );
663 }
664
665 // ── 29. site_content_volume ──────────────────────────────────────
666 // Sizes the site's frontend-reachable content to scope the planned Complete
667 // Site Scan. Banded, not raw, so the breakdown stays one row per band;
668 // `count` keeps the exact figure in case the bands need redrawing.
669 $content_count = $this->get_public_content_count();
670 $events->track(
671 'site_content_volume',
672 $this->get_content_volume_bucket( $content_count ),
673 [
674 'version' => SURECOOKIE_VERSION,
675 'count' => (string) $content_count,
676 ]
677 );
678
679 /**
680 * Fires after SureCookie has queued its own state-based events, letting
681 * add-ons (e.g. SureCookie Pro) push their events through the shared
682 * tracker. Runs once per throttled detection cycle.
683 *
684 * @since 1.2.0
685 * @param \BSF_Analytics_Events $events Shared event tracker.
686 * @param int $days_since_install Days since plugin install.
687 */
688 do_action( 'surecookie_detect_state_events', $events, $days_since_install );
689 }
690
691 // ============================================
692 // KPI Tracking
693 // ============================================
694
695 /**
696 * Get KPI tracking data for the last 2 days (excluding today).
697 *
698 * @since 0.0.1-beta.1
699 * @return array<string, array<string, array<string, int>>> KPI records keyed by date.
700 */
701 private function get_kpi_tracking_data(): array {
702 $kpi_records = [];
703
704 for ( $i = 1; $i <= 2; $i++ ) {
705 $date = wp_date( 'Y-m-d', strtotime( "-{$i} days" ) );
706
707 if ( ! $date ) {
708 continue;
709 }
710
711 $kpi_records[ $date ] = [
712 'numeric_values' => [
713 'consent_logs' => $this->get_daily_consent_log_count( $date ),
714 ],
715 ];
716 }
717
718 return $kpi_records;
719 }
720
721 /**
722 * Get daily consent log entry count for a specific date.
723 *
724 * @param string $date Date in Y-m-d format.
725 * @since 0.0.1-beta.1
726 * @return int Count of consent log entries for that date.
727 */
728 private function get_daily_consent_log_count( string $date ): int {
729 global $wpdb;
730
731 $table = $wpdb->prefix . SURECOOKIE_CONSENT_LOG_DB;
732
733 return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
734 $wpdb->prepare(
735 "SELECT COUNT(*) FROM {$table} WHERE DATE(timestamp) = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
736 $date
737 )
738 );
739 }
740
741 // ============================================
742 // Helpers
743 // ============================================
744
745 /**
746 * Count published entries across every frontend-reachable post type.
747 *
748 * Mirrors core's sitemap definition - public and viewable, minus attachments -
749 * so the total matches what a full-site crawl would have to visit.
750 *
751 * @since x.x.x
752 * @return int Published public entries across posts, pages and public CPTs.
753 */
754 private function get_public_content_count(): int {
755 $post_types = get_post_types( [ 'public' => true ], 'names' );
756 unset( $post_types['attachment'] );
757
758 $total = 0;
759 foreach ( array_filter( $post_types, 'is_post_type_viewable' ) as $post_type ) {
760 $counts = (array) wp_count_posts( $post_type );
761 $total += (int) ( $counts['publish'] ?? 0 );
762 }
763
764 return $total;
765 }
766
767 /**
768 * Resolve a content count to its volume band.
769 *
770 * @param int $count Published public entries.
771 * @since x.x.x
772 * @return string Band name, e.g. `less_than_50`.
773 */
774 private function get_content_volume_bucket( int $count ): string {
775 foreach ( self::CONTENT_VOLUME_BUCKETS as $upper_bound => $bucket ) {
776 if ( $count < $upper_bound ) {
777 return $bucket;
778 }
779 }
780
781 return 'greater_than_1000';
782 }
783
784 /**
785 * Get number of days since plugin installation.
786 *
787 * @since 0.0.1-beta.1
788 * @return int Days since install (0 if unknown).
789 */
790 private function get_days_since_install(): int {
791 $install_time = (int) get_option( 'surecookie_usage_installed_time', 0 );
792
793 if ( $install_time <= 0 ) {
794 return 0;
795 }
796
797 return (int) floor( ( time() - $install_time ) / DAY_IN_SECONDS );
798 }
799
800 /**
801 * Get total scans from the database.
802 *
803 * @since 0.0.1-beta.1
804 * @return int Total number of scans.
805 */
806 private function get_total_scans(): int {
807 $option = get_option(
808 SURECOOKIE_SCANNED_DETAILS_OPTION,
809 [
810 'total_scans' => 0,
811 ]
812 );
813
814 return isset( $option['total_scans'] ) ? (int) $option['total_scans'] : 0;
815 }
816 }
817