api_key = defined( 'WPVR_TELEMETRY_API_KEY' ) ? WPVR_TELEMETRY_API_KEY : ''; $this->api_secret = defined( 'WPVR_TELEMETRY_API_SECRET' ) ? WPVR_TELEMETRY_API_SECRET : ''; $this->init_client(); add_action( 'transition_post_status', array( $this, 'track_tour_created_event' ), 10, 3 ); add_filter( 'wpvr_telemetry_report_interval', array( $this, 'set_daily_telemetry_report_interval' ) ); add_action( 'rex_wpvr_embadded_tour', array( $this, 'handle_embedded_tour_view' ), 10, 1 ); add_action( 'wpvr_telemetry_consent_granted', array( $this, 'track_aha_after_consent' ) ); add_filter( 'wpvr_telemetry_deactivation_reasons', array( $this, 'set_deactivation_reasons' ) ); } /** * Force Linno telemetry queue interval to daily. * * @return string */ public function set_daily_telemetry_report_interval() { return 'daily'; } /** * Initialize Linno telemetry client and trigger mappings. * * @return void */ public function init_client() { global $wpvr_telemetry; if ( $this->client_initialized ) { return; } if ( ! class_exists( 'LinnoSDK\\Telemetry\\Client' ) || ! defined( 'WPVR_FILE' ) ) { error_log( 'WPVR Telemetry: Linno Client class unavailable or WPVR_FILE not defined.' ); return; } if ( empty( $this->api_key ) || empty( $this->api_secret ) ) { error_log( 'WPVR Telemetry: API key or API secret is empty. Telemetry client not initialized.' ); return; } Client::set_text_domain( 'wpvr' ); $wpvr_telemetry = new Client( array( 'pluginFile' => WPVR_FILE, 'slug' => 'wpvr', 'pluginName' => 'WP VR', 'version' => defined( 'WPVR_VERSION' ) ? WPVR_VERSION : '', 'apiKey' => $this->api_key, 'apiSecret' => $this->api_secret, 'driver' => 'posthog', 'driver_config' => array( 'host' => defined( 'WPVR_TELEMETRY_HOST' ) ? WPVR_TELEMETRY_HOST : 'https://eu.i.posthog.com', 'api_key' => $this->api_key, ), ) ); // Non-PII onboarding event — bypasses consent via override=true on track_immediate(). add_action( 'wpvr_setup_wizard_completed_event', array( $this, 'track_onboarding_event' ), 10, 1 ); // Consent-gated events via SDK trigger system. // activation/aha_reached — queued when ≥60% of tours are actively viewed (recurring). // retention/feature_used — queued on every tour save. // Both require opt-in consent. $self = $this; $wpvr_telemetry->define_triggers( array( 'aha' => array( 'tours_actively_viewed' => array( 'hook' => 'wpvr_kui_unique_views_updated', 'callback' => function ( $unique_views, $tour_id ) use ( $self ) { return $self->build_kui_payload( $unique_views, $tour_id ); }, ), ), 'feature_used' => array( 'tour_creation' => array( 'hook' => 'wpvr_tour_settings_saved', 'callback' => function ( $tour_id ) use ( $self ) { return $self->build_tour_creation_payload( $tour_id ); }, ), ), ) ); $this->ensure_daily_telemetry_queue_schedule(); $this->client_initialized = true; } /** * Ensure telemetry queue cron hook runs daily. * * @return void */ private function ensure_daily_telemetry_queue_schedule() { if ( ! defined( 'WPVR_FILE' ) ) { return; } $slug = dirname( plugin_basename( WPVR_FILE ) ); $hook = $slug . '_telemetry_queue_process'; $scheduled_event = wp_get_scheduled_event( $hook ); if ( $scheduled_event && 'daily' === $scheduled_event->schedule ) { return; } wp_clear_scheduled_hook( $hook ); wp_schedule_event( time(), 'daily', $hook ); } /** * Build setup event payload. * * @param string $industry Industry from setup wizard. * @return array */ public function build_setup_payload( $industry = '' ) { return array( 'industry' => sanitize_text_field( (string) $industry ), 'time' => current_time( 'mysql' ), ); } /** * Build KUI payload. * * @param int $unique_views Unique active-tour views in 7 days. * @param int $tour_id Last viewed tour ID. * @return array */ public function build_kui_payload( $unique_views = 0, $tour_id = 0 ) { return array( 'unique_views' => absint( $unique_views ), 'tour_id' => absint( $tour_id ), 'window_days' => 7, 'time' => current_time( 'mysql' ), ); } /** * Build tour creation payload with feature usage as properties. * * Fires when tour settings are saved. Inspects the stored panodata meta to * determine which core features the user has configured, so all feature * usage is captured in a single event instead of separate ones. * * @param int $tour_id Tour (post) ID. * @return array */ public function build_tour_creation_payload( $tour_id = 0 ) { $tour_id = absint( $tour_id ); $panodata = get_post_meta( $tour_id, 'panodata', true ); $hotspot_editor = 'no'; $floor_plan = 'no'; $tour_builder = 'no'; if ( is_array( $panodata ) ) { // Floor plan — added to panodata by pro version via filter. if ( isset( $panodata['floor_plan_tour_enabler'] ) && 'on' === $panodata['floor_plan_tour_enabler'] && ! empty( $panodata['floor_plan_attachment_url'] ) ) { $floor_plan = 'yes'; } // Scene list lives one level deeper inside panodata. $scene_list = isset( $panodata['panodata']['scene-list'] ) ? $panodata['panodata']['scene-list'] : array(); if ( is_array( $scene_list ) ) { // Tour builder = more than one scene configured. if ( count( $scene_list ) > 1 ) { $tour_builder = 'yes'; } // Hotspot editor = at least one non-empty hotspot in any scene. foreach ( $scene_list as $scene ) { if ( ! empty( $scene['hotspot-list'] ) && is_array( $scene['hotspot-list'] ) ) { $hotspot_editor = 'yes'; break; } } } } return array( 'hotspot_editor' => $hotspot_editor, 'floor_plan' => $floor_plan, 'tour_builder' => $tour_builder, ); } /** * Build real tour creation payload. * * @param int $tour_id Tour (post) ID. * @return array */ public function build_real_tour_creation_payload( $tour_id = 0 ) { $tour_id = absint( $tour_id ); return array( 'tour_id' => $tour_id, 'created_via' => get_post_meta( $tour_id, 'wpvr_created_from_wizard', true ) ? 'wizard' : 'manual', 'time' => current_time( 'mysql' ), ); } /** * Track retention/tour_created when a real tour is first published. * * This event is consent-gated like other retention events. It is emitted * only once per tour and skipped for demo/sample tours. * * @param string $new_status New post status. * @param string $old_status Old post status. * @param WP_Post $post Post object. * @return void */ public function track_tour_created_event( $new_status, $old_status, $post ) { global $wpvr_telemetry; if ( ! is_object( $wpvr_telemetry ) || ! $post instanceof WP_Post ) { return; } if ( 'wpvr_item' !== $post->post_type || ! $this->is_first_publish_transition( $new_status, $old_status ) ) { return; } if ( $this->is_seeded_tour( $post->ID ) || $this->has_tracked_tour_created_event( $post->ID ) ) { return; } $wpvr_telemetry->track( 'retention/tour_created', $this->build_real_tour_creation_payload( $post->ID ) ); update_post_meta( $post->ID, self::TOUR_CREATED_TRACKED_META_KEY, '1' ); } /** * Determine if a publish transition represents a first real publish. * * @param string $new_status New post status. * @param string $old_status Old post status. * @return bool */ private function is_first_publish_transition( $new_status, $old_status ) { if ( 'publish' !== $new_status ) { return false; } return in_array( $old_status, array( 'auto-draft', 'draft', 'new', 'pending', 'private', 'future', '' ), true ); } /** * Determine whether a tour is seeded demo/sample content. * * @param int $tour_id Tour (post) ID. * @return bool */ private function is_seeded_tour( $tour_id ) { return '1' === (string) get_post_meta( absint( $tour_id ), 'wpvr_is_demo_tour', true ); } /** * Check whether the creation event has already been emitted. * * @param int $tour_id Tour (post) ID. * @return bool */ private function has_tracked_tour_created_event( $tour_id ) { return '1' === (string) get_post_meta( absint( $tour_id ), self::TOUR_CREATED_TRACKED_META_KEY, true ); } /** * Track onboarding completed event (non-PII, bypasses consent gate). * * @param string $industry Industry from setup wizard. * @return void */ public function track_onboarding_event( $industry = '' ) { global $wpvr_telemetry; if ( ! is_object( $wpvr_telemetry ) ) { return; } if ( method_exists( $wpvr_telemetry, 'has_sent_event' ) && $wpvr_telemetry->has_sent_event( 'onboarding_completed' ) ) { return; } $wpvr_telemetry->track_immediate( 'activation/onboarding_completed', $this->build_setup_payload( $industry ), true ); if ( method_exists( $wpvr_telemetry, 'mark_event_sent' ) ) { $wpvr_telemetry->mark_event_sent( 'onboarding_completed' ); } } /** * Track activation/aha_reached after consent is granted. * * The wizard completion hook fires BEFORE consent is set (separate AJAX calls), * so aha cannot use define_triggers. Instead, this method fires once when * consent is granted on the Success step, if the wizard was completed. * * @return void */ public function track_aha_after_consent() { global $wpvr_telemetry; if ( ! is_object( $wpvr_telemetry ) ) { return; } // Only fire if the wizard was actually completed. if ( ! get_option( 'wpvr_wizard_onboarding_done' ) ) { return; } // One-shot guard — never send twice. if ( method_exists( $wpvr_telemetry, 'has_sent_event' ) && $wpvr_telemetry->has_sent_event( 'aha_reached_wizard_completed' ) ) { return; } $industry = sanitize_text_field( get_option( 'wpvr_industry_name', '' ) ); $wpvr_telemetry->track_kui( 'wizard_completed', array( 'industry' => $industry, 'time' => current_time( 'mysql' ), ) ); if ( method_exists( $wpvr_telemetry, 'mark_event_sent' ) ) { $wpvr_telemetry->mark_event_sent( 'aha_reached_wizard_completed' ); } } /** * Update rolling 7-day unique active-tour views and emit KUI trigger event. * * @param int $tour_id Tour ID. * @return void */ public function handle_embedded_tour_view( $tour_id ) { $tour_id = absint( $tour_id ); if ( $tour_id <= 0 ) { return; } $tour = get_post( $tour_id ); if ( ! $tour || 'wpvr_item' !== $tour->post_type || 'publish' !== $tour->post_status ) { return; } $option_key = 'wpvr_kui_unique_views_7d'; $last_kui_sent_key = 'wpvr_kui_unique_views_7d_last_sent_at'; $window_seconds = 7 * DAY_IN_SECONDS; $now = time(); $tracked_views = get_option( $option_key, array() ); if ( ! is_array( $tracked_views ) ) { $tracked_views = array(); } foreach ( $tracked_views as $tracked_tour_id => $timestamp ) { $tracked_tour_id = absint( $tracked_tour_id ); $timestamp = absint( $timestamp ); if ( $tracked_tour_id <= 0 || $timestamp <= 0 || ( $now - $timestamp ) > $window_seconds || 'publish' !== get_post_status( $tracked_tour_id ) ) { unset( $tracked_views[ $tracked_tour_id ] ); } } $tracked_views[ $tour_id ] = $now; update_option( $option_key, $tracked_views ); $unique_views = count( $tracked_views ); $published_tours = wp_count_posts( 'wpvr_item' ); $total_published_tours = 0; if ( is_object( $published_tours ) && isset( $published_tours->publish ) ) { $total_published_tours = absint( $published_tours->publish ); } $active_tour_ratio = 0; if ( $total_published_tours > 0 ) { $active_tour_ratio = (float) ( $unique_views / $total_published_tours ); } update_option( 'wpvr_last_active_tour_ratio', $active_tour_ratio ); $last_kui_sent_at = absint( get_option( $last_kui_sent_key, 0 ) ); $minimum_active_tour_ratio = 0.6; if ( $active_tour_ratio < $minimum_active_tour_ratio ) { return; } if ( $last_kui_sent_at > 0 && ( $now - $last_kui_sent_at ) < $window_seconds ) { return; } do_action( 'wpvr_kui_unique_views_updated', $unique_views, $tour_id ); update_option( $last_kui_sent_key, $now ); } /** * Replace generic deactivation reasons with VR-specific ones. * * @param array $reasons * @return array */ public function set_deactivation_reasons( $reasons ) { return array( array( 'id' => 'interface_too_complex', 'text' => __( 'Setup / Interface is too complex', 'wpvr' ), 'placeholder' => __( 'What was the biggest blocker?', 'wpvr' ), 'icon' => '', ), array( 'id' => 'missing_vr_feature', 'text' => __( 'Missing specific VR feature', 'wpvr' ), 'placeholder' => __( 'Which feature? (Floor Plan, WebVR, etc.)', 'wpvr' ), 'icon' => '', ), array( 'id' => 'tour_not_displaying', 'text' => __( 'Tour not displaying / Error', 'wpvr' ), 'placeholder' => __( 'Any error message or URL?', 'wpvr' ), 'icon' => '', ), array( 'id' => 'performance_issue', 'text' => __( 'Image quality / Performance issues', 'wpvr' ), 'placeholder' => __( 'Image size or browser details help.', 'wpvr' ), 'icon' => '', ), array( 'id' => 'found_better_solution', 'text' => __( 'Found a better solution', 'wpvr' ), 'placeholder' => __( 'Which tool? What did it do better?', 'wpvr' ), 'icon' => '', ), array( 'id' => 'just_testing', 'text' => __( 'Just testing / One-time project', 'wpvr' ), 'placeholder' => __( 'What were you building?', 'wpvr' ), 'icon' => '', ), ); } } new WPVRLinnoTelemetry();