PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.3
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.3
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / vendor / linno / telemetry / src / Client.php

Client.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.3, at vendor/linno/telemetry/src/Client.php

1,158 lines 36.4 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 $minimal_properties['plugin_version'] = sanitize_text_field( (string) ( $properties['plugin_version'] ?? $this->config['version'] ?? '' ) );
617 }
618
619 $result = $this->handlers['dispatcher']->dispatch_minimal( $event, $minimal_properties );
620
621 if ( $result ) {
622 update_option( $this->config['slug'] . '_telemetry_last_send', time(), false );
623 }
624 }
625
626 /**
627 * Check if opt-in is enabled
628 *
629 * Checks if the user has opted in to telemetry tracking.
630 *
631 * @return bool True if opt-in is enabled, false otherwise.
632 * @since 1.0.0
633 */
634 private function isOptInEnabled(): bool {
635 return 'yes' === $this->get_optin_state();
636 }
637
638 /**
639 * Get normalized consent state from current or legacy keys.
640 *
641 * Priority order:
642 * 1) Linno global key
643 * 2) Current plugin's Appsero-style key ({slug}_allow_tracking)
644 * 3) Known legacy Appsero keys
645 *
646 * If a legacy key is found and Linno global key is missing,
647 * the value is migrated to Linno global key for future reads.
648 *
649 * @return string|null Returns 'yes', 'no', or null when no decision exists.
650 */
651 public function get_optin_state(): ?string {
652 $global_state = $this->normalize_optin_value( get_option( $this->get_optin_key(), null ) );
653 if ( null !== $global_state ) {
654 return $global_state;
655 }
656
657 foreach ( $this->get_legacy_optin_keys() as $legacy_key ) {
658 $legacy_state = $this->normalize_optin_value( get_option( $legacy_key, null ) );
659 if ( null !== $legacy_state ) {
660 update_option( $this->get_optin_key(), $legacy_state );
661 return $legacy_state;
662 }
663 }
664
665 return null;
666 }
667
668 /**
669 * Persist consent decision to Linno key and current plugin legacy key.
670 *
671 * @param string $state Accepted values: 'yes' or 'no'.
672 * @return void
673 */
674 public function set_optin_state( string $state ): void {
675 $normalized_state = $this->normalize_optin_value( $state );
676
677 if ( null === $normalized_state ) {
678 return;
679 }
680
681 update_option( $this->get_optin_key(), $normalized_state );
682 update_option( $this->get_slug() . '_allow_tracking', $normalized_state );
683
684 if ( 'yes' === $normalized_state ) {
685 $this->finalize_optin_setup();
686 }
687 }
688
689 /**
690 * Synchronize telemetry side effects after consent changes.
691 *
692 * Use this when consent is managed in a custom onboarding flow and
693 * state was persisted outside set_optin_state().
694 *
695 * @return void
696 */
697 public function sync_consent_state(): void {
698 if ( $this->isOptInEnabled() ) {
699 $this->finalize_optin_setup();
700 }
701 }
702
703 /**
704 * Get all legacy consent keys to check for migration.
705 *
706 * @return array
707 */
708 public function get_legacy_optin_keys(): array {
709 return array_values(
710 array_unique(
711 array_merge(
712 array( $this->get_slug() . '_allow_tracking' ),
713 self::LEGACY_APPSERO_OPTIN_KEYS
714 )
715 )
716 );
717 }
718
719 /**
720 * Normalize stored consent value.
721 *
722 * @param mixed $value
723 * @return string|null
724 */
725 private function normalize_optin_value( $value ): ?string {
726 if ( is_string( $value ) ) {
727 $value = strtolower( trim( $value ) );
728 if ( 'yes' === $value ) {
729 return 'yes';
730 }
731 if ( 'no' === $value ) {
732 return 'no';
733 }
734 }
735
736 return null;
737 }
738
739 /**
740 * Get the option key for tracking consent.
741 *
742 * @return string
743 */
744 public function get_optin_key(): string {
745 return self::GLOBAL_OPTIN_KEY;
746 }
747
748 /**
749 * Get the global option key for notice dismissal.
750 *
751 * @return string
752 */
753 public function get_notice_dismissed_key(): string {
754 return self::GLOBAL_NOTICE_DISMISSED_KEY;
755 }
756
757 /**
758 * Get the plugin slug.
759 *
760 * @return string
761 */
762 public function get_slug(): string {
763 return $this->config['slug'];
764 }
765
766 /**
767 * Get the plugin file path.
768 *
769 * @return string
770 */
771 public function get_plugin_file(): string {
772 return $this->config['pluginFile'];
773 }
774
775 /**
776 * Get the plugin name.
777 *
778 * @return string
779 */
780 public function get_plugin_name(): string {
781 return $this->config['pluginName'];
782 }
783
784 /**
785 * Get the unique ID for the site.
786 *
787 * @return string
788 */
789 public function get_unique_id(): string {
790 return $this->config['unique_id'];
791 }
792
793 /**
794 * Get the client instance for a specific plugin
795 *
796 * Static method to retrieve the telemetry client for a plugin.
797 *
798 * @param string $plugin_file The main plugin file path
799 * @return Client|null The client instance or null if not found
800 * @since 1.0.0
801 */
802 public static function getInstance( string $plugin_file ): ?Client {
803 $base_name = plugin_basename( $plugin_file );
804 $slug = dirname( $base_name );
805 $safe_slug = str_replace( '-', '_', $slug );
806 $global_name = $safe_slug . '_telemetry_client';
807 return $GLOBALS[ $global_name ] ?? null;
808 }
809
810 /**
811 * Track a 'setup' event.
812 *
813 * This event is sent only once after the plugin setup is completed.
814 * Requires user consent.
815 *
816 * @param array $properties Additional properties for the event.
817 * @return void
818 */
819 public function track_setup( array $properties = [] ): void {
820 if ( $this->has_sent_event( 'onboarding_completed' ) ) {
821 return;
822 }
823
824 if ( ! $this->isOptInEnabled() ) {
825 return;
826 }
827
828 $this->track( 'activation/onboarding_completed', $properties );
829 $this->mark_event_sent( 'onboarding_completed' );
830 }
831
832
833
834 /**
835 * Track a 'kui' (Key Usage Indicator) event.
836 *
837 * This event can be sent multiple times when the user gets significant value from the plugin.
838 * Requires user consent.
839 *
840 * @param string $kui_name The name of the KUI event (e.g., 'funnel_order_received').
841 * @param array $properties Additional properties for the event.
842 * @return void
843 */
844 public function track_kui( string $kui_name, array $properties = [] ): void {
845 $this->track( 'activation/aha_reached', array_merge( [ 'indicator' => $kui_name ], $properties ) );
846 }
847
848 /**
849 * Track a 'feature_used' event.
850 *
851 * This event is sent when the user uses a core feature of the product.
852 * Requires user consent.
853 *
854 * @param string $feature_name The name of the feature.
855 * @param array $properties Additional properties for the event.
856 * @return void
857 */
858 public function track_feature_used( string $feature_name, array $properties = [] ): void {
859 $this->track( 'retention/feature_used', array_merge( [ 'feature' => $feature_name ], $properties ) );
860 }
861
862 /**
863 * Register a WordPress action hook that sends a retention/feature_used event when triggered.
864 *
865 * This static convenience method attaches a callback to the given WordPress
866 * action hook. When that hook fires, a `retention/feature_used` event is
867 * dispatched through every active Client instance initialized on the current
868 * request.
869 *
870 * @param string $hook_name WordPress action hook to listen for.
871 * @param string $feature_name Name of the feature being tracked.
872 * @param array $params Optional key-value pairs sent with the event.
873 * @return void
874 */
875 public static function add_feature_used_event( string $hook_name, string $feature_name, array $params = [] ): void {
876 add_action( $hook_name, function() use ( $feature_name, $params ) {
877 foreach ( self::$instances as $instance ) {
878 $instance->track_feature_used( $feature_name, $params );
879 }
880 } );
881 }
882
883 /**
884 * Get the TriggerManager instance
885 *
886 * Provides access to configure automatic event triggers.
887 *
888 * @return TriggerManager
889 * @since 1.0.0
890 */
891 public function triggers(): TriggerManager {
892 if ( null === $this->trigger_manager ) {
893 $this->trigger_manager = new TriggerManager( $this );
894 }
895 return $this->trigger_manager;
896 }
897
898 /**
899 * Define automatic triggers for PLG events
900 *
901 * Simplified method to configure all triggers at once.
902 *
903 * @param array $config Configuration array with:
904 * - setup: hook name or ['hook' => hook_name, 'callback' => callable]
905 * - kui: array of KUI configurations
906 * - feature_used: array of feature used configurations
907 * @return self
908 * @since 1.0.0
909 */
910 public function define_triggers( array $config ): self {
911 $triggers = $this->triggers();
912
913 // setup → fires activation/onboarding_completed (once)
914 if ( isset( $config['setup'] ) ) {
915 $hook = is_array( $config['setup'] ) ? $config['setup']['hook'] : $config['setup'];
916 $callback = is_array( $config['setup'] ) ? ( $config['setup']['callback'] ?? null ) : null;
917 $triggers->on_setup( $hook, $callback );
918 }
919
920 // onboarding → canonical alias for setup
921 if ( isset( $config['onboarding'] ) ) {
922 $hook = is_array( $config['onboarding'] ) ? $config['onboarding']['hook'] : $config['onboarding'];
923 $callback = is_array( $config['onboarding'] ) ? ( $config['onboarding']['callback'] ?? null ) : null;
924 $triggers->on_setup( $hook, $callback );
925 }
926
927
928
929 // kui → fires activation/aha_reached for each defined indicator
930 if ( isset( $config['kui'] ) && is_array( $config['kui'] ) ) {
931 foreach ( $config['kui'] as $name => $kui_config ) {
932 if ( is_array( $kui_config ) ) {
933 $triggers->on_kui( $name, $kui_config );
934 }
935 }
936 }
937
938 // aha → canonical alias for kui
939 if ( isset( $config['aha'] ) && is_array( $config['aha'] ) ) {
940 foreach ( $config['aha'] as $name => $aha_config ) {
941 if ( is_array( $aha_config ) ) {
942 $triggers->on_kui( $name, $aha_config );
943 }
944 }
945 }
946
947 // feature_used → fires retention/feature_used for each defined feature
948 if ( isset( $config['feature_used'] ) && is_array( $config['feature_used'] ) ) {
949 foreach ( $config['feature_used'] as $name => $feature_config ) {
950 if ( is_array( $feature_config ) && isset($feature_config['hook'])) {
951 $triggers->on_feature_used( $name, $feature_config['hook'], $feature_config['callback'] ?? null );
952 }
953 }
954 }
955
956 // Register all newly-defined triggers so their WordPress hooks fire.
957 $triggers->init();
958
959 return $this;
960 }
961
962 /**
963 * Initialize trigger manager
964 *
965 * Must be called after defining triggers and before init completes.
966 *
967 * @return void
968 * @since 1.0.0
969 */
970 private function init_triggers(): void {
971 if ( null !== $this->trigger_manager ) {
972 $this->trigger_manager->init();
973 }
974 }
975
976 /**
977 * Check if a specific event has already been sent.
978 *
979 * @param string $event_name The name of the event to check.
980 * @return bool True if the event has been sent, false otherwise.
981 * @since 1.0.0
982 */
983 public function has_sent_event( string $event_name ): bool {
984 return 'yes' === get_option( $this->config['slug'] . '_event_sent_' . $event_name, 'no' );
985 }
986
987 /**
988 * Mark a specific event as sent.
989 *
990 * @param string $event_name The name of the event to mark as sent.
991 * @return void
992 * @since 1.0.0
993 */
994 public function mark_event_sent( string $event_name ): void {
995 update_option( $this->config['slug'] . '_event_sent_' . $event_name, 'yes' );
996 }
997
998 /**
999 * Schedule background reporting via WP-Cron
1000 *
1001 * Creates a weekly cron job for sending system info events.
1002 * Allows customization via a filter.
1003 *
1004 * @return void
1005 * @since 1.0.0
1006 */
1007 private function scheduleBackgroundReporting(): void {
1008 $hook = $this->get_cron_hook();
1009
1010 // Hook callback for weekly report
1011 add_action( $hook, array( $this, 'process_queue' ) );
1012
1013 // Schedule cron job if not already scheduled
1014 if ( ! wp_next_scheduled( $hook ) ) {
1015 // Apply filter for customizable interval (default: daily)
1016 $interval = apply_filters( $this->config['slug'] . '_telemetry_report_interval', 'daily' );
1017
1018 // Schedule the event
1019 wp_schedule_event( time(), $interval, $hook );
1020 }
1021 }
1022
1023 /**
1024 * Upgrade consent state to the current schema version.
1025 *
1026 * This intentionally clears prior consent + notice state that may have
1027 * been influenced by legacy compatibility behavior, so only new
1028 * Linno consent decisions are used going forward.
1029 *
1030 * @return void
1031 */
1032 private function maybe_upgrade_consent_state(): void {
1033 $current_version = (string) get_option( self::GLOBAL_CONSENT_VERSION_KEY, '' );
1034
1035 if ( self::CONSENT_VERSION === $current_version ) {
1036 return;
1037 }
1038
1039 delete_option( self::GLOBAL_OPTIN_KEY );
1040 delete_option( self::GLOBAL_NOTICE_DISMISSED_KEY );
1041
1042 update_option( self::GLOBAL_CONSENT_VERSION_KEY, self::CONSENT_VERSION, false );
1043 }
1044
1045 /**
1046 * Unschedule background reporting
1047 *
1048 * Removes the scheduled cron job for system info reporting.
1049 * Called when consent is revoked.
1050 *
1051 * @return void
1052 * @since 1.0.0
1053 */
1054 private function unscheduleBackgroundReporting(): void {
1055 $hook = $this->get_cron_hook();
1056 $timestamp = wp_next_scheduled( $hook );
1057 if ( $timestamp ) {
1058 wp_unschedule_event( $timestamp, $hook );
1059 }
1060 }
1061
1062 /**
1063 * Get the cron hook name.
1064 *
1065 * @return string
1066 */
1067 public function get_cron_hook(): string {
1068 return $this->config['slug'] . '_telemetry_queue_process';
1069 }
1070
1071 /**
1072 * Finalize required setup once user has granted telemetry consent.
1073 *
1074 * Creates queue table (once) and flushes pending activation tracking.
1075 *
1076 * @return void
1077 */
1078 private function finalize_optin_setup(): void {
1079 if ( ! get_option( self::GLOBAL_TABLE_CREATED_KEY ) ) {
1080 $this->create_queue_table();
1081 update_option( self::GLOBAL_TABLE_CREATED_KEY, 'yes' );
1082 }
1083
1084 $this->scheduleBackgroundReporting();
1085 }
1086
1087 /**
1088 * Process the event queue
1089 *
1090 * Callback for the cron job. Sends events from the queue if opt-in is enabled.
1091 *
1092 * @return void
1093 * @since 1.0.0
1094 */
1095 public function process_queue(): void {
1096 if ( ! $this->isOptInEnabled() ) {
1097 return;
1098 }
1099
1100 if ( ! $this->handlers['queue']->table_exists() ) {
1101 return;
1102 }
1103
1104 $events = $this->handlers['queue']->get_all( $this->config['slug'] );
1105
1106 if ( empty( $events ) ) {
1107 return;
1108 }
1109
1110 $ids_to_delete = [];
1111
1112 foreach ( $events as $event ) {
1113 $properties = json_decode( $event->properties, true );
1114 $result = $this->handlers['dispatcher']->dispatch( $event->event, $properties );
1115
1116 if ( $result ) {
1117 $ids_to_delete[] = $event->id;
1118 update_option( $this->config['slug'] . '_telemetry_last_send', time(), false );
1119 }
1120 }
1121
1122 if ( ! empty( $ids_to_delete ) ) {
1123 $this->handlers['queue']->delete( $ids_to_delete );
1124
1125 // Reset KUI counters after successful reporting
1126 if ( null !== $this->trigger_manager ) {
1127 $this->trigger_manager->reset_all_counters();
1128 }
1129 }
1130 }
1131
1132
1133 /**
1134 * Set the slug for the plugin
1135 *
1136 * @return void
1137 */
1138 private function set_slug() {
1139 $this->config['slug'] = dirname( plugin_basename( $this->config['pluginFile'] ) );
1140 }
1141
1142 /**
1143 * Get or create a unique ID for the site.
1144 *
1145 * @return string
1146 */
1147 private function get_or_create_unique_id(): string {
1148 $unique_id = get_option( self::GLOBAL_UNIQUE_ID_KEY );
1149
1150 if ( empty( $unique_id ) ) {
1151 $unique_id = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid();
1152 update_option( self::GLOBAL_UNIQUE_ID_KEY, $unique_id, false );
1153 }
1154
1155 return $unique_id;
1156 }
1157 }
1158