PluginProbe
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress / 8.5.68
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress v8.5.68
9.1.3 9.1.2 9.1.1 9.1.0 9.0.3 9.0.2 9.0.1 9.0.0 8.5.79 8.5.78 8.5.77 8.5.76 8.5.75 8.5.74 8.5.73 8.5.72 8.5.71 8.5.70 8.5.69 8.5.68 8.5.35 8.5.36 8.5.37 8.5.38 8.5.39 All 222 releases
wpvr / vendor / linno / telemetry / src / Client.php

Client.php in WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress 8.5.68, at vendor/linno/telemetry/src/Client.php

1,157 lines 36.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Client Class
4 *
5 * Main entry point for plugin developers to integrate telemetry tracking.
6 * Handles initialization, configuration, and provides the public API for tracking events.
7 *
8 * @package LinnoSDK\Telemetry
9 * @since 1.0.0
10 */
11
12 namespace LinnoSDK\Telemetry;
13
14 use LinnoSDK\Telemetry\Drivers\DriverInterface;
15 use LinnoSDK\Telemetry\Drivers\NullDriver;
16 use LinnoSDK\Telemetry\Drivers\OpenPanelDriver;
17 use LinnoSDK\Telemetry\Drivers\PostHogDriver;
18 use LinnoSDK\Telemetry\Helpers\Utils;
19 use InvalidArgumentException;
20
21 /**
22 * Client class
23 *
24 * Provides the main API for telemetry tracking with event dispatching
25 * and background reporting.
26 *
27 * @since 1.0.0
28 */
29 class Client {
30 /**
31 * Global option key for telemetry consent.
32 */
33 private const GLOBAL_OPTIN_KEY = 'linno_telemetry_allow_tracking';
34
35 /**
36 * Consent schema version marker.
37 */
38 private const GLOBAL_CONSENT_VERSION_KEY = 'linno_telemetry_consent_version';
39
40 /**
41 * Current consent schema version.
42 */
43 private const CONSENT_VERSION = '2';
44
45 /**
46 * Known legacy Appsero consent option keys.
47 */
48 private const LEGACY_APPSERO_OPTIN_KEYS = array(
49 'best-woocommerce-feed_allow_tracking',
50 'wpvr_allow_tracking',
51 'wpfunnels_allow_tracking',
52 'cart-lift_allow_tracking',
53 'creatorlms_allow_tracking',
54 'mail-mint_allow_tracking',
55 );
56
57 /**
58 * Global option key for telemetry queue table creation state.
59 */
60 private const GLOBAL_TABLE_CREATED_KEY = 'linno_telemetry_table_created';
61
62 /**
63 * Global option key for telemetry notice dismissal state.
64 */
65 private const GLOBAL_NOTICE_DISMISSED_KEY = 'linno_telemetry_notice_dismissed';
66
67 /**
68 * Global option key for shared telemetry unique ID.
69 */
70 private const GLOBAL_UNIQUE_ID_KEY = 'linno_telemetry_unique_id';
71
72 /**
73 * Configuration data (apiKey, apiSecret, pluginName, pluginFile, slug, version, unique_id)
74 *
75 * @var array
76 */
77 private array $config = [];
78
79 /**
80 * Text domain for i18n
81 *
82 * @var string
83 */
84 private static string $textDomain = '';
85
86 /**
87 * Privacy policy URL for consent notice Learn more link.
88 *
89 * @var string
90 */
91 private static string $privacyUrl = 'https://rextheme.com/privacy-policy/';
92
93 /**
94 * Analytics service label used in consent notice message.
95 *
96 * @var string
97 */
98 private static string $consentServiceName = 'our analytics service';
99
100 /**
101 * Registry of all active Client instances.
102 *
103 * Used by add_feature_used_event to dispatch events across all initialized clients.
104 *
105 * @var Client[]
106 */
107 private static array $instances = [];
108
109 /**
110 * Handlers (dispatcher, consent, deactivation, queue)
111 *
112 * @var array
113 */
114 private array $handlers = [];
115
116 /**
117 * TriggerManager instance
118 *
119 * @var TriggerManager|null
120 */
121 private ?TriggerManager $trigger_manager = null;
122
123 /**
124 * ReviewPrompt instance.
125 *
126 * Populated when a 'review_prompt' config array is provided, or when
127 * enable_review_prompt() is called after construction.
128 *
129 * @var ReviewPrompt|null
130 */
131 private ?ReviewPrompt $review_prompt = null;
132
133 /**
134 * Constructor
135 *
136 * Accepts either an array configuration or the legacy 4-positional-parameter signature.
137 *
138 * Array form (current):
139 * new Client(['pluginFile' => ..., 'slug' => ..., ...])
140 *
141 * Legacy positional form (deprecated):
142 * new Client($apiKey, $apiSecret, $pluginName, $pluginFile)
143 *
144 * @param array|string $configOrApiKey Configuration array, or API key string for legacy form.
145 * @param string $apiSecret (Legacy) API secret.
146 * @param string $pluginName (Legacy) Human-readable plugin name.
147 * @param string $pluginFile (Legacy) Path to the main plugin file.
148 *
149 * @throws InvalidArgumentException If the first argument is not an array or string, or if
150 * required fields are missing.
151 * @since 1.0.0
152 */
153 public function __construct($configOrApiKey, string $apiSecret = '', string $pluginName = '', string $pluginFile = '')
154 {
155 if ( is_array( $configOrApiKey ) ) {
156 $config = $configOrApiKey;
157
158 if (empty($config['pluginFile']) || empty($config['slug'])) {
159 throw new InvalidArgumentException('The "pluginFile" and "slug" parameters are required.');
160 }
161 } elseif ( is_string( $configOrApiKey ) ) {
162 $config = $this->buildLegacyConfig( $configOrApiKey, $apiSecret, $pluginName, $pluginFile );
163 } else {
164 throw new InvalidArgumentException( 'First argument must be a configuration array or a string API key' );
165 }
166
167 $this->config = array_merge([
168 'apiKey' => '',
169 'apiSecret' => '',
170 'pluginName' => '',
171 'version' => '',
172 'unique_id' => '',
173 'driver' => '',
174 'driver_config' => [],
175 ], $config);
176
177 // Normalize version key: accept both 'version' and 'pluginVersion'
178 if ( empty( $this->config['version'] ) && ! empty( $this->config['pluginVersion'] ) ) {
179 $this->config['version'] = $this->config['pluginVersion'];
180 }
181
182 // Ensure unique_id is populated
183 if ( empty( $this->config['unique_id'] ) ) {
184 $this->config['unique_id'] = $this->get_or_create_unique_id();
185 }
186
187 self::$textDomain = $this->config['slug'];
188
189 $driver = $this->resolve_driver();
190
191 $this->handlers = [
192 'dispatcher' => new EventDispatcher( $driver, $this->config ),
193 'consent' => new Consent( $this ),
194 'deactivation' => new Deactivation( $this ),
195 'queue' => new Queue(),
196 ];
197
198 self::$instances[] = $this;
199
200 $this->init();
201 }
202
203 public function getDispatcher(): EventDispatcher
204 {
205 return $this->handlers['dispatcher'];
206 }
207
208 /**
209 * Build a config array from legacy 4-positional-parameter constructor arguments.
210 *
211 * @param string $apiKey API key.
212 * @param string $apiSecret API secret.
213 * @param string $pluginName Human-readable plugin name.
214 * @param string $pluginFile Path to the main plugin file.
215 * @return array
216 * @throws InvalidArgumentException If any required parameter is missing or empty.
217 */
218 private function buildLegacyConfig( string $apiKey, string $apiSecret, string $pluginName, string $pluginFile ): array
219 {
220 if ( '' === $apiSecret && '' === $pluginName && '' === $pluginFile ) {
221 throw new InvalidArgumentException( 'Legacy constructor requires exactly 4 string parameters' );
222 }
223
224 if ( '' === $apiKey ) {
225 throw new InvalidArgumentException( 'API key must not be empty' );
226 }
227
228 if ( '' === $pluginFile ) {
229 throw new InvalidArgumentException( 'Plugin file path must not be empty' );
230 }
231
232 if ( '' === $pluginName ) {
233 throw new InvalidArgumentException( 'Plugin name must not be empty' );
234 }
235
236 trigger_error(
237 'Passing positional parameters to LinnoSDK\Telemetry\Client::__construct() is deprecated. Use an array configuration instead. See https://github.com/user/coderex-telemetry#migration for details. This will be removed in the next major version.',
238 E_USER_DEPRECATED
239 );
240
241 return [
242 'apiKey' => $apiKey,
243 'apiSecret' => $apiSecret,
244 'pluginName' => $pluginName,
245 'pluginFile' => $pluginFile,
246 'slug' => sanitize_title( $pluginName ),
247 'driver' => 'open_panel',
248 ];
249 }
250
251 /**
252 * Get a copy of the current configuration array.
253 *
254 * @return array
255 */
256 public function getConfig(): array
257 {
258 return $this->config;
259 }
260
261 /**
262 * Resolve the configured telemetry driver.
263 *
264 * Supports an injected test driver via config key '_test_driver' for unit tests.
265 * Falls back to NullDriver with a warning when the driver is missing or unrecognized.
266 *
267 * @return DriverInterface
268 */
269 private function resolve_driver(): DriverInterface
270 {
271 // Allow test injection without touching the real driver factories.
272 if ( ! empty( $this->config['_test_driver'] ) && $this->config['_test_driver'] instanceof DriverInterface ) {
273 return $this->config['_test_driver'];
274 }
275
276 $driver_type = strtolower( trim( $this->config['driver'] ?? '' ) );
277
278 if ( 'posthog' === $driver_type ) {
279 if ( ! class_exists( \PostHog\PostHog::class ) ) {
280 error_log( '[Linno Telemetry] Warning: PostHog SDK not found. Install posthog/posthog-php or switch to a supported driver. Falling back to NullDriver.' );
281 return new NullDriver();
282 }
283 $host = $this->config['driver_config']['host'] ?? '';
284 $driver = new PostHogDriver( $host );
285 $driver->setApiKey( $this->config['driver_config']['api_key'] ?? $this->config['apiKey'] ?? '' );
286 return $driver;
287 }
288
289 if ( 'open_panel' === $driver_type ) {
290 $driver = new OpenPanelDriver();
291 $driver->setApiKey( $this->config['apiKey'] ?? '' );
292 if ( method_exists( $driver, 'setApiSecret' ) ) {
293 $driver->setApiSecret( $this->config['apiSecret'] ?? '' );
294 }
295 return $driver;
296 }
297
298 if ( '' !== $driver_type ) {
299 error_log( sprintf(
300 '[Linno Telemetry] Warning: Unrecognized driver "%s". Supported drivers: open_panel, posthog. Falling back to NullDriver.',
301 $driver_type
302 ) );
303 } else {
304 error_log( '[Linno Telemetry] Warning: No telemetry driver configured. Events will be silently dropped. Set the "driver" key to "open_panel" or "posthog" to enable tracking.' );
305 }
306
307 return new NullDriver();
308 }
309
310 /**
311 * WordPress action handler for the generic custom-event hook.
312 *
313 * Registered as: add_action( '<slug>_telemetry_track', ... )
314 *
315 * @param string $event_name The event name.
316 * @param array $properties Optional associative properties array.
317 * @return void
318 */
319 public function handle_telemetry_action( string $event_name, array $properties = [] ): void {
320 $this->track( $event_name, $properties );
321 }
322
323 /**
324 * Get the text domain.
325 *
326 * @return string
327 */
328 public function get_text_domain(): string {
329 return self::$textDomain;
330 }
331
332 /**
333 * Set the text domain.
334 *
335 * @param string $textDomain
336 */
337 public static function set_text_domain( string $textDomain ): void {
338 self::$textDomain = $textDomain;
339 }
340
341 /**
342 * Get the privacy policy URL used for Learn more link.
343 *
344 * @return string
345 */
346 public function get_privacy_url(): string {
347 return self::$privacyUrl;
348 }
349
350 /**
351 * Set the privacy policy URL used for Learn more link.
352 *
353 * @param string $privacyUrl
354 * @return void
355 */
356 public static function set_privacy_url( string $privacyUrl ): void {
357 if ( ! empty( $privacyUrl ) ) {
358 self::$privacyUrl = $privacyUrl;
359 }
360 }
361
362 /**
363 * Get the analytics service label used in consent message.
364 *
365 * @return string
366 */
367 public function get_consent_service_name(): string {
368 return self::$consentServiceName;
369 }
370
371 /**
372 * Set the analytics service label used in consent message.
373 *
374 * @param string $serviceName
375 * @return void
376 */
377 public static function set_consent_service_name( string $serviceName ): void {
378 if ( ! empty( $serviceName ) ) {
379 self::$consentServiceName = $serviceName;
380 }
381 }
382
383 /**
384 * Initialize the telemetry client
385 *
386 * This method should be called by the plugin developer to initialize the hooks.
387 *
388 * @return void
389 */
390 public function init(): void {
391 $this->maybe_upgrade_consent_state();
392
393 if ( ! empty( self::$textDomain ) ) {
394 load_plugin_textdomain( self::$textDomain, false, dirname( plugin_basename( $this->config['pluginFile'] ) ) . '/languages' );
395 }
396
397 $this->handlers['consent']->init();
398 $this->handlers['deactivation']->init();
399 $this->init_triggers();
400 $this->init_review_prompt();
401
402 // Internally register activation and deactivation hooks
403 register_activation_hook( $this->config['pluginFile'], [ $this, 'activate' ] );
404 register_deactivation_hook( $this->config['pluginFile'], [ $this, 'deactivate' ] );
405
406 // Register the generic custom-event action hook: <slug>_telemetry_track
407 add_action(
408 $this->config['slug'] . '_telemetry_track',
409 [ $this, 'handle_telemetry_action' ],
410 10,
411 2
412 );
413
414 // Ensure post-consent setup is completed for already-consented sites.
415 if ( $this->isOptInEnabled() ) {
416 $this->finalize_optin_setup();
417 }
418 }
419
420 /**
421 * Initialize the review prompt if config was supplied.
422 *
423 * @return void
424 */
425 private function init_review_prompt(): void {
426 if ( isset( $this->config['review_prompt'] ) && is_array( $this->config['review_prompt'] ) ) {
427 $this->review_prompt = new ReviewPrompt( $this, $this->config['review_prompt'] );
428 $this->review_prompt->init();
429 }
430 }
431
432 /**
433 * Enable (or reconfigure) the review prompt after construction.
434 *
435 * Can be called at any time before admin hooks fire.
436 *
437 * @param array $config ReviewPrompt config array (see ReviewPrompt class docblock).
438 * @return self
439 */
440 public function enable_review_prompt( array $config = [] ): self {
441 $this->review_prompt = new ReviewPrompt( $this, $config );
442 $this->review_prompt->init();
443 return $this;
444 }
445
446 /**
447 * Get the ReviewPrompt instance, or null if not enabled.
448 *
449 * @return ReviewPrompt|null
450 */
451 public function get_review_prompt(): ?ReviewPrompt {
452 return $this->review_prompt;
453 }
454
455 /**
456 * Plugin activation hook.
457 *
458 * @return void
459 */
460 public function activate(): void {
461 // Track activation without consent using minimal non-personal payload.
462 if ( ! get_option( $this->config['slug'] . '_telemetry_activated_tracked' ) ) {
463 $this->track_lifecycle_event(
464 'activation/plugin_activated',
465 [
466 'site_url' => get_site_url(),
467 ]
468 );
469
470 update_option( $this->config['slug'] . '_telemetry_activated_tracked', 'yes' );
471 }
472 }
473
474 /**
475 * Create the queue table.
476 *
477 * @return void
478 * @since 1.0.1
479 */
480 public function create_queue_table(): void {
481 $this->handlers['queue']->create_table();
482 }
483
484 /**
485 * Plugin deactivation hook.
486 *
487 * This method should be called from the plugin's deactivation hook.
488 * It clears all pending events for this plugin from the queue.
489 *
490 * @return void
491 * @since 1.0.1
492 */
493 public function deactivate(): void {
494 // Check if the deactivation event was already sent by the feedback form
495 $transient_key = $this->get_slug() . '_deactivation_event_sent';
496 if ( 'yes' !== get_transient( $transient_key ) ) {
497 // Send a generic deactivation event if the feedback form didn't send one
498 $this->track_lifecycle_event(
499 'activation/plugin_deactivated',
500 [
501 'site_url' => get_site_url(),
502 'reason' => 'none',
503 ]
504 );
505 }
506 // Clean up the transient regardless
507 delete_transient( $transient_key );
508
509 $this->handlers['queue']->clear_for_plugin( $this->config['slug'] );
510
511 $this->unscheduleBackgroundReporting();
512 }
513
514
515 /**
516 * Track an event immediately
517 *
518 * Sends an event directly without adding it to the queue.
519 *
520 * @param string $event Event name.
521 * @param array $properties Event properties (optional).
522 * @param bool $override Whether to override the opt-in check.
523 *
524 * @return void
525 * @since 1.0.1
526 */
527 public function track_immediate( string $event, array $properties = array(), bool $override = false ): void {
528 // Check if opt-in is enabled
529 if ( ! $override && ! $this->isOptInEnabled() ) {
530 return;
531 }
532
533 // Prepare full properties with metadata
534 $properties = $this->prepare_properties( $properties );
535
536 $result = $this->handlers['dispatcher']->dispatch( $event, $properties );
537
538 if ( $result ) {
539 update_option( $this->config['slug'] . '_telemetry_last_send', time(), false );
540 } else {
541 // Fallback: Add to queue if immediate send fails
542 $this->handlers['queue']->add( $this->config['slug'], $event, $properties );
543 }
544 }
545
546 /**
547 * Track a custom event
548 *
549 * Adds a custom event to the queue if opt-in is enabled.
550 *
551 * @param string $event Event name.
552 * @param array $properties Event properties (optional).
553 * @param bool $override Whether to override the opt-in check.
554 *
555 * @return void
556 * @since 1.0.0
557 */
558 public function track( string $event, array $properties = array(), bool $override = false ): void {
559 // Check if opt-in is enabled
560 if ( ! $override && ! $this->isOptInEnabled() ) {
561 return;
562 }
563
564 // Prepare full properties with metadata
565 $properties = $this->prepare_properties( $properties );
566
567 // Add event to queue
568 $this->handlers['queue']->add( $this->config['slug'], $event, $properties );
569 }
570
571 /**
572 * Prepare event properties with all necessary metadata.
573 *
574 * @param array $properties Original properties.
575 * @return array Enriched properties.
576 */
577 private function prepare_properties( array $properties ): array {
578 // Add metadata if not already present
579 $properties['site_url'] = $properties['site_url'] ?? get_site_url();
580 $properties['unique_id'] = $properties['unique_id'] ?? $this->config['unique_id'];
581 $properties['plugin_name'] = $properties['plugin_name'] ?? $this->config['pluginName'];
582 $properties['plugin_version'] = $properties['plugin_version'] ?? $this->config['version'] ?? '';
583 $properties['timestamp'] = $properties['timestamp'] ?? Utils::getCurrentTimestamp();
584
585 // Add user identification context if not already present
586 if ( ! isset( $properties['__identify'] ) ) {
587 $properties['__identify'] = Utils::get_current_user_identify();
588 }
589
590 return $properties;
591 }
592
593 /**
594 * Track lifecycle events without consent using a strict non-personal payload.
595 *
596 * Allowed properties:
597 * - plugin_activated: site_url, unique_id
598 * - plugin_deactivated: site_url, unique_id, reason
599 *
600 * @param string $event Event name.
601 * @param array $properties Event properties.
602 *
603 * @return void
604 */
605 public function track_lifecycle_event( string $event, array $properties = array() ): void {
606 $minimal_properties = array(
607 'site_url' => esc_url_raw( (string) ( $properties['site_url'] ?? get_site_url() ) ),
608 'unique_id' => sanitize_text_field( (string) ( $properties['unique_id'] ?? $this->config['unique_id'] ) ),
609 '__identify' => array(
610 'profileId' => Utils::getSiteProfileId(),
611 ),
612 );
613
614 if ( 'activation/plugin_deactivated' === $event ) {
615 $minimal_properties['reason'] = sanitize_text_field( (string) ( $properties['reason'] ?? 'none' ) );
616 }
617
618 $result = $this->handlers['dispatcher']->dispatch_minimal( $event, $minimal_properties );
619
620 if ( $result ) {
621 update_option( $this->config['slug'] . '_telemetry_last_send', time(), false );
622 }
623 }
624
625 /**
626 * Check if opt-in is enabled
627 *
628 * Checks if the user has opted in to telemetry tracking.
629 *
630 * @return bool True if opt-in is enabled, false otherwise.
631 * @since 1.0.0
632 */
633 private function isOptInEnabled(): bool {
634 return 'yes' === $this->get_optin_state();
635 }
636
637 /**
638 * Get normalized consent state from current or legacy keys.
639 *
640 * Priority order:
641 * 1) Linno global key
642 * 2) Current plugin's Appsero-style key ({slug}_allow_tracking)
643 * 3) Known legacy Appsero keys
644 *
645 * If a legacy key is found and Linno global key is missing,
646 * the value is migrated to Linno global key for future reads.
647 *
648 * @return string|null Returns 'yes', 'no', or null when no decision exists.
649 */
650 public function get_optin_state(): ?string {
651 $global_state = $this->normalize_optin_value( get_option( $this->get_optin_key(), null ) );
652 if ( null !== $global_state ) {
653 return $global_state;
654 }
655
656 foreach ( $this->get_legacy_optin_keys() as $legacy_key ) {
657 $legacy_state = $this->normalize_optin_value( get_option( $legacy_key, null ) );
658 if ( null !== $legacy_state ) {
659 update_option( $this->get_optin_key(), $legacy_state );
660 return $legacy_state;
661 }
662 }
663
664 return null;
665 }
666
667 /**
668 * Persist consent decision to Linno key and current plugin legacy key.
669 *
670 * @param string $state Accepted values: 'yes' or 'no'.
671 * @return void
672 */
673 public function set_optin_state( string $state ): void {
674 $normalized_state = $this->normalize_optin_value( $state );
675
676 if ( null === $normalized_state ) {
677 return;
678 }
679
680 update_option( $this->get_optin_key(), $normalized_state );
681 update_option( $this->get_slug() . '_allow_tracking', $normalized_state );
682
683 if ( 'yes' === $normalized_state ) {
684 $this->finalize_optin_setup();
685 }
686 }
687
688 /**
689 * Synchronize telemetry side effects after consent changes.
690 *
691 * Use this when consent is managed in a custom onboarding flow and
692 * state was persisted outside set_optin_state().
693 *
694 * @return void
695 */
696 public function sync_consent_state(): void {
697 if ( $this->isOptInEnabled() ) {
698 $this->finalize_optin_setup();
699 }
700 }
701
702 /**
703 * Get all legacy consent keys to check for migration.
704 *
705 * @return array
706 */
707 public function get_legacy_optin_keys(): array {
708 return array_values(
709 array_unique(
710 array_merge(
711 array( $this->get_slug() . '_allow_tracking' ),
712 self::LEGACY_APPSERO_OPTIN_KEYS
713 )
714 )
715 );
716 }
717
718 /**
719 * Normalize stored consent value.
720 *
721 * @param mixed $value
722 * @return string|null
723 */
724 private function normalize_optin_value( $value ): ?string {
725 if ( is_string( $value ) ) {
726 $value = strtolower( trim( $value ) );
727 if ( 'yes' === $value ) {
728 return 'yes';
729 }
730 if ( 'no' === $value ) {
731 return 'no';
732 }
733 }
734
735 return null;
736 }
737
738 /**
739 * Get the option key for tracking consent.
740 *
741 * @return string
742 */
743 public function get_optin_key(): string {
744 return self::GLOBAL_OPTIN_KEY;
745 }
746
747 /**
748 * Get the global option key for notice dismissal.
749 *
750 * @return string
751 */
752 public function get_notice_dismissed_key(): string {
753 return self::GLOBAL_NOTICE_DISMISSED_KEY;
754 }
755
756 /**
757 * Get the plugin slug.
758 *
759 * @return string
760 */
761 public function get_slug(): string {
762 return $this->config['slug'];
763 }
764
765 /**
766 * Get the plugin file path.
767 *
768 * @return string
769 */
770 public function get_plugin_file(): string {
771 return $this->config['pluginFile'];
772 }
773
774 /**
775 * Get the plugin name.
776 *
777 * @return string
778 */
779 public function get_plugin_name(): string {
780 return $this->config['pluginName'];
781 }
782
783 /**
784 * Get the unique ID for the site.
785 *
786 * @return string
787 */
788 public function get_unique_id(): string {
789 return $this->config['unique_id'];
790 }
791
792 /**
793 * Get the client instance for a specific plugin
794 *
795 * Static method to retrieve the telemetry client for a plugin.
796 *
797 * @param string $plugin_file The main plugin file path
798 * @return Client|null The client instance or null if not found
799 * @since 1.0.0
800 */
801 public static function getInstance( string $plugin_file ): ?Client {
802 $base_name = plugin_basename( $plugin_file );
803 $slug = dirname( $base_name );
804 $safe_slug = str_replace( '-', '_', $slug );
805 $global_name = $safe_slug . '_telemetry_client';
806 return $GLOBALS[ $global_name ] ?? null;
807 }
808
809 /**
810 * Track a 'setup' event.
811 *
812 * This event is sent only once after the plugin setup is completed.
813 * Requires user consent.
814 *
815 * @param array $properties Additional properties for the event.
816 * @return void
817 */
818 public function track_setup( array $properties = [] ): void {
819 if ( $this->has_sent_event( 'onboarding_completed' ) ) {
820 return;
821 }
822
823 if ( ! $this->isOptInEnabled() ) {
824 return;
825 }
826
827 $this->track( 'activation/onboarding_completed', $properties );
828 $this->mark_event_sent( 'onboarding_completed' );
829 }
830
831
832
833 /**
834 * Track a 'kui' (Key Usage Indicator) event.
835 *
836 * This event can be sent multiple times when the user gets significant value from the plugin.
837 * Requires user consent.
838 *
839 * @param string $kui_name The name of the KUI event (e.g., 'funnel_order_received').
840 * @param array $properties Additional properties for the event.
841 * @return void
842 */
843 public function track_kui( string $kui_name, array $properties = [] ): void {
844 $this->track( 'activation/aha_reached', array_merge( [ 'indicator' => $kui_name ], $properties ) );
845 }
846
847 /**
848 * Track a 'feature_used' event.
849 *
850 * This event is sent when the user uses a core feature of the product.
851 * Requires user consent.
852 *
853 * @param string $feature_name The name of the feature.
854 * @param array $properties Additional properties for the event.
855 * @return void
856 */
857 public function track_feature_used( string $feature_name, array $properties = [] ): void {
858 $this->track( 'retention/feature_used', array_merge( [ 'feature' => $feature_name ], $properties ) );
859 }
860
861 /**
862 * Register a WordPress action hook that sends a retention/feature_used event when triggered.
863 *
864 * This static convenience method attaches a callback to the given WordPress
865 * action hook. When that hook fires, a `retention/feature_used` event is
866 * dispatched through every active Client instance initialized on the current
867 * request.
868 *
869 * @param string $hook_name WordPress action hook to listen for.
870 * @param string $feature_name Name of the feature being tracked.
871 * @param array $params Optional key-value pairs sent with the event.
872 * @return void
873 */
874 public static function add_feature_used_event( string $hook_name, string $feature_name, array $params = [] ): void {
875 add_action( $hook_name, function() use ( $feature_name, $params ) {
876 foreach ( self::$instances as $instance ) {
877 $instance->track_feature_used( $feature_name, $params );
878 }
879 } );
880 }
881
882 /**
883 * Get the TriggerManager instance
884 *
885 * Provides access to configure automatic event triggers.
886 *
887 * @return TriggerManager
888 * @since 1.0.0
889 */
890 public function triggers(): TriggerManager {
891 if ( null === $this->trigger_manager ) {
892 $this->trigger_manager = new TriggerManager( $this );
893 }
894 return $this->trigger_manager;
895 }
896
897 /**
898 * Define automatic triggers for PLG events
899 *
900 * Simplified method to configure all triggers at once.
901 *
902 * @param array $config Configuration array with:
903 * - setup: hook name or ['hook' => hook_name, 'callback' => callable]
904 * - kui: array of KUI configurations
905 * - feature_used: array of feature used configurations
906 * @return self
907 * @since 1.0.0
908 */
909 public function define_triggers( array $config ): self {
910 $triggers = $this->triggers();
911
912 // setup → fires activation/onboarding_completed (once)
913 if ( isset( $config['setup'] ) ) {
914 $hook = is_array( $config['setup'] ) ? $config['setup']['hook'] : $config['setup'];
915 $callback = is_array( $config['setup'] ) ? ( $config['setup']['callback'] ?? null ) : null;
916 $triggers->on_setup( $hook, $callback );
917 }
918
919 // onboarding → canonical alias for setup
920 if ( isset( $config['onboarding'] ) ) {
921 $hook = is_array( $config['onboarding'] ) ? $config['onboarding']['hook'] : $config['onboarding'];
922 $callback = is_array( $config['onboarding'] ) ? ( $config['onboarding']['callback'] ?? null ) : null;
923 $triggers->on_setup( $hook, $callback );
924 }
925
926
927
928 // kui → fires activation/aha_reached for each defined indicator
929 if ( isset( $config['kui'] ) && is_array( $config['kui'] ) ) {
930 foreach ( $config['kui'] as $name => $kui_config ) {
931 if ( is_array( $kui_config ) ) {
932 $triggers->on_kui( $name, $kui_config );
933 }
934 }
935 }
936
937 // aha → canonical alias for kui
938 if ( isset( $config['aha'] ) && is_array( $config['aha'] ) ) {
939 foreach ( $config['aha'] as $name => $aha_config ) {
940 if ( is_array( $aha_config ) ) {
941 $triggers->on_kui( $name, $aha_config );
942 }
943 }
944 }
945
946 // feature_used → fires retention/feature_used for each defined feature
947 if ( isset( $config['feature_used'] ) && is_array( $config['feature_used'] ) ) {
948 foreach ( $config['feature_used'] as $name => $feature_config ) {
949 if ( is_array( $feature_config ) && isset($feature_config['hook'])) {
950 $triggers->on_feature_used( $name, $feature_config['hook'], $feature_config['callback'] ?? null );
951 }
952 }
953 }
954
955 // Register all newly-defined triggers so their WordPress hooks fire.
956 $triggers->init();
957
958 return $this;
959 }
960
961 /**
962 * Initialize trigger manager
963 *
964 * Must be called after defining triggers and before init completes.
965 *
966 * @return void
967 * @since 1.0.0
968 */
969 private function init_triggers(): void {
970 if ( null !== $this->trigger_manager ) {
971 $this->trigger_manager->init();
972 }
973 }
974
975 /**
976 * Check if a specific event has already been sent.
977 *
978 * @param string $event_name The name of the event to check.
979 * @return bool True if the event has been sent, false otherwise.
980 * @since 1.0.0
981 */
982 public function has_sent_event( string $event_name ): bool {
983 return 'yes' === get_option( $this->config['slug'] . '_event_sent_' . $event_name, 'no' );
984 }
985
986 /**
987 * Mark a specific event as sent.
988 *
989 * @param string $event_name The name of the event to mark as sent.
990 * @return void
991 * @since 1.0.0
992 */
993 public function mark_event_sent( string $event_name ): void {
994 update_option( $this->config['slug'] . '_event_sent_' . $event_name, 'yes' );
995 }
996
997 /**
998 * Schedule background reporting via WP-Cron
999 *
1000 * Creates a weekly cron job for sending system info events.
1001 * Allows customization via a filter.
1002 *
1003 * @return void
1004 * @since 1.0.0
1005 */
1006 private function scheduleBackgroundReporting(): void {
1007 $hook = $this->get_cron_hook();
1008
1009 // Hook callback for weekly report
1010 add_action( $hook, array( $this, 'process_queue' ) );
1011
1012 // Schedule cron job if not already scheduled
1013 if ( ! wp_next_scheduled( $hook ) ) {
1014 // Apply filter for customizable interval (default: daily)
1015 $interval = apply_filters( $this->config['slug'] . '_telemetry_report_interval', 'daily' );
1016
1017 // Schedule the event
1018 wp_schedule_event( time(), $interval, $hook );
1019 }
1020 }
1021
1022 /**
1023 * Upgrade consent state to the current schema version.
1024 *
1025 * This intentionally clears prior consent + notice state that may have
1026 * been influenced by legacy compatibility behavior, so only new
1027 * Linno consent decisions are used going forward.
1028 *
1029 * @return void
1030 */
1031 private function maybe_upgrade_consent_state(): void {
1032 $current_version = (string) get_option( self::GLOBAL_CONSENT_VERSION_KEY, '' );
1033
1034 if ( self::CONSENT_VERSION === $current_version ) {
1035 return;
1036 }
1037
1038 delete_option( self::GLOBAL_OPTIN_KEY );
1039 delete_option( self::GLOBAL_NOTICE_DISMISSED_KEY );
1040
1041 update_option( self::GLOBAL_CONSENT_VERSION_KEY, self::CONSENT_VERSION, false );
1042 }
1043
1044 /**
1045 * Unschedule background reporting
1046 *
1047 * Removes the scheduled cron job for system info reporting.
1048 * Called when consent is revoked.
1049 *
1050 * @return void
1051 * @since 1.0.0
1052 */
1053 private function unscheduleBackgroundReporting(): void {
1054 $hook = $this->get_cron_hook();
1055 $timestamp = wp_next_scheduled( $hook );
1056 if ( $timestamp ) {
1057 wp_unschedule_event( $timestamp, $hook );
1058 }
1059 }
1060
1061 /**
1062 * Get the cron hook name.
1063 *
1064 * @return string
1065 */
1066 public function get_cron_hook(): string {
1067 return $this->config['slug'] . '_telemetry_queue_process';
1068 }
1069
1070 /**
1071 * Finalize required setup once user has granted telemetry consent.
1072 *
1073 * Creates queue table (once) and flushes pending activation tracking.
1074 *
1075 * @return void
1076 */
1077 private function finalize_optin_setup(): void {
1078 if ( ! get_option( self::GLOBAL_TABLE_CREATED_KEY ) ) {
1079 $this->create_queue_table();
1080 update_option( self::GLOBAL_TABLE_CREATED_KEY, 'yes' );
1081 }
1082
1083 $this->scheduleBackgroundReporting();
1084 }
1085
1086 /**
1087 * Process the event queue
1088 *
1089 * Callback for the cron job. Sends events from the queue if opt-in is enabled.
1090 *
1091 * @return void
1092 * @since 1.0.0
1093 */
1094 public function process_queue(): void {
1095 if ( ! $this->isOptInEnabled() ) {
1096 return;
1097 }
1098
1099 if ( ! $this->handlers['queue']->table_exists() ) {
1100 return;
1101 }
1102
1103 $events = $this->handlers['queue']->get_all( $this->config['slug'] );
1104
1105 if ( empty( $events ) ) {
1106 return;
1107 }
1108
1109 $ids_to_delete = [];
1110
1111 foreach ( $events as $event ) {
1112 $properties = json_decode( $event->properties, true );
1113 $result = $this->handlers['dispatcher']->dispatch( $event->event, $properties );
1114
1115 if ( $result ) {
1116 $ids_to_delete[] = $event->id;
1117 update_option( $this->config['slug'] . '_telemetry_last_send', time(), false );
1118 }
1119 }
1120
1121 if ( ! empty( $ids_to_delete ) ) {
1122 $this->handlers['queue']->delete( $ids_to_delete );
1123
1124 // Reset KUI counters after successful reporting
1125 if ( null !== $this->trigger_manager ) {
1126 $this->trigger_manager->reset_all_counters();
1127 }
1128 }
1129 }
1130
1131
1132 /**
1133 * Set the slug for the plugin
1134 *
1135 * @return void
1136 */
1137 private function set_slug() {
1138 $this->config['slug'] = dirname( plugin_basename( $this->config['pluginFile'] ) );
1139 }
1140
1141 /**
1142 * Get or create a unique ID for the site.
1143 *
1144 * @return string
1145 */
1146 private function get_or_create_unique_id(): string {
1147 $unique_id = get_option( self::GLOBAL_UNIQUE_ID_KEY );
1148
1149 if ( empty( $unique_id ) ) {
1150 $unique_id = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid();
1151 update_option( self::GLOBAL_UNIQUE_ID_KEY, $unique_id, false );
1152 }
1153
1154 return $unique_id;
1155 }
1156 }
1157