PluginProbe
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress / 8.5.69
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress v8.5.69
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.69, at vendor/linno/telemetry/src/Client.php

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