PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | admin/analytics.php +165 -14 2.12.4 → 2.12.8 View file →
@@ -6,9 +6,11 @@
6 6 */
7 7
8 8 namespace SRFM\Admin;
9 9
10 +use SRFM\Inc\Database\Register;
10 11 use SRFM\Inc\Database\Tables\Entries;
12 +use SRFM\Inc\Form_Views;
11 13 use SRFM\Inc\Helper;
12 14 use SRFM\Inc\Learn;
13 15 use SRFM\Inc\Traits\Get_Instance;
14 16
@@ -128,9 +130,9 @@
128 130 * @since 1.4.0
129 131 * @return array
130 132 */
131 133 public function add_srfm_analytics_data( $stats_data ) {
132 - $stats_data['plugin_data']['sureforms'] = [
134 + $stats_data['plugin_data']['sureforms'] = [
133 135 'free_version' => SRFM_VER,
134 136 'site_language' => get_locale(),
135 137 'most_used_anti_spam' => $this->most_used_anti_spam(),
136 138 'user_status' => $this->user_status(),
@@ -135,8 +137,12 @@
135 137 'most_used_anti_spam' => $this->most_used_anti_spam(),
136 138 'user_status' => $this->user_status(),
137 139 'pointer_popup_clicked' => $this->pointer_popup_clicked(),
138 140 ];
141 + // Every query against the entries table errors when the table is missing, so
142 + // resolve that once here rather than letting each caller below trip over it.
143 + $entries_table_missing = Register::is_entries_table_missing();
144 +
139 145 $stats_data['plugin_data']['sureforms']['numeric_values'] = [
140 146 'total_forms' => wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0,
141 147 'instant_forms_enabled' => $this->instant_forms_enabled(),
142 148 'forms_using_custom_css' => $this->forms_using_custom_css(),
@@ -142,15 +148,22 @@
142 148 'forms_using_custom_css' => $this->forms_using_custom_css(),
143 149 'ai_generated_forms' => $this->ai_generated_forms(),
144 150 'ai_generated_payment_forms' => $this->ai_generated_forms( 'payments' ),
145 151 'payment_forms' => $this->get_payment_forms_count(),
146 - 'total_entries' => Entries::get_total_entries_by_status(),
152 + 'total_entries' => $entries_table_missing ? 0 : Entries::get_total_entries_by_status(),
147 153 'restricted_forms' => $this->get_restricted_forms(),
148 154 'embed_styling_gb_default' => self::embed_styling_gutenberg_count( 'default' ),
149 155 'embed_styling_el_default' => self::embed_styling_elementor_count( 'default' ),
150 156 'embed_styling_br_default' => self::embed_styling_bricks_count( 'default' ),
157 + 'total_form_views' => $this->total_form_views(),
158 + 'forms_with_views' => $this->forms_with_views(),
151 159 ];
152 160
161 + // Whether the entries table is currently missing. An event fires once when a
162 + // site first sees the notice; this is what shows the state persisting, and
163 + // catches a recurrence that the event's one-time dedup would swallow.
164 + $stats_data['plugin_data']['sureforms']['boolean_values']['db_entries_table_missing'] = $entries_table_missing;
165 +
153 166 $stats_data['plugin_data']['sureforms'] = array_merge_recursive( $stats_data['plugin_data']['sureforms'], $this->global_settings_data() );
154 167 // Add KPI tracking data.
155 168 $kpi_data = $this->get_kpi_tracking_data();
156 169 if ( ! empty( $kpi_data ) ) {
@@ -487,12 +500,88 @@
487 500
488 501 // Payment analytics - check if any payment method is enabled.
489 502 $global_data['boolean_values']['stripe_enabled'] = $this->is_stripe_enabled();
490 503
504 + // Delegated rather than read from $general_settings directly, so the telemetry
505 + // can never disagree with what the Forms list actually shows. That matters
506 + // because the default has already changed once: reimplementing the check here
507 + // means a future change has two places to be made and one to be forgotten.
508 + $global_data['boolean_values']['form_views_columns_enabled'] = Form_Views::get_instance()->is_tracking_enabled();
509 +
491 510 return $global_data;
492 511 }
493 512
494 513 /**
514 + * Total page views counted across all published forms.
515 + *
516 + * Read alongside `forms_with_views`, which is what makes this figure legible: a
517 + * site with views but no forms recording any reads very differently from one
518 + * with neither.
519 + *
520 + * Not a denominator for `total_entries`, and the two must not be divided. This
521 + * total starts at the moment tracking opened and leaves out anyone who can edit
522 + * the site, while `total_entries` is an unfiltered all-time count of every
523 + * entry ever received. A rate built from the pair answers neither question and
524 + * will not match the Conversion Rate column, which measures both halves over
525 + * the same window with the same exclusion.
526 + *
527 + * Counts only published forms, so views left behind by a trashed or draft form
528 + * do not inflate the total against a denominator that no longer includes them.
529 + *
530 + * @since 2.12.6
531 + * @return int
532 + */
533 + public function total_form_views() {
534 + global $wpdb;
535 +
536 + // Deliberately uncached: the only caller is the analytics payload builder,
537 + // which runs once per cron pass. A cache would never see a second read inside
538 + // a request, and any TTL would always have expired between passes — while
539 + // still going stale if a second caller ever appeared.
540 + // PHPCS: Ignore direct database query warning, as there is no built-in alternative.
541 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
542 + $total = $wpdb->get_var(
543 + $wpdb->prepare(
544 + "
545 + SELECT SUM( pm.meta_value )
546 + FROM {$wpdb->postmeta} pm
547 + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
548 + WHERE pm.meta_key = %s
549 + AND p.post_type = %s
550 + AND p.post_status = 'publish'
551 + ",
552 + Form_Views::META_KEY,
553 + SRFM_FORMS_POST_TYPE
554 + )
555 + );
556 +
557 + return Helper::get_integer_value( $total );
558 + }
559 +
560 + /**
561 + * Number of published forms that have been viewed at least once.
562 + *
563 + * Distinguishes "the feature is on but nothing is embedded anywhere" from "the
564 + * beacon is firing" — a total alone cannot, because one busy form looks the
565 + * same as many quiet ones.
566 + *
567 + * @since 2.12.6
568 + * @return int
569 + */
570 + public function forms_with_views() {
571 + return $this->custom_wp_query_total_posts(
572 + [
573 + [
574 + 'key' => Form_Views::META_KEY,
575 + 'value' => 0,
576 + 'compare' => '>',
577 + 'type' => 'NUMERIC',
578 + ],
579 + ]
580 + );
581 + }
582 +
583 + /**
495 584 * Returns user status.
496 585 *
497 586 * @since 1.8.0
498 587 * @return string
@@ -719,8 +808,24 @@
719 808 );
720 809 }
721 810
722 811 /**
812 + * Track the plugin_activated event with the correct install referer.
813 + *
814 + * Dedup in self::events()->track() ensures this fires only once. Runs on
815 + * 'shutdown' (see detect_state_events()) so it reads bsf_product_referers
816 + * after any late-writing referer call has had a chance to run.
817 + *
818 + * @since 2.12.6
819 + * @return void
820 + */
821 + public function track_plugin_activated_event() {
822 + $bsf_referrers = get_option( 'bsf_product_referers', [] );
823 + $source = ! empty( $bsf_referrers['sureforms'] ) ? $bsf_referrers['sureforms'] : 'self';
824 + self::events()->track( 'plugin_activated', SRFM_VER, [ 'source' => $source ] );
825 + }
826 +
827 + /**
723 828 * Extract non-inherit formTheme values from Bricks element data.
724 829 *
725 830 * Recursively walks the unserialized Bricks elements array looking for
726 831 * sureforms elements with custom formTheme settings.
@@ -823,8 +928,16 @@
823 928 * @since 2.4.0
824 929 * @return int Daily submissions count.
825 930 */
826 931 private function get_daily_submissions_count( $date ) {
932 + // Guarded here rather than at the call site: this is the function that runs
933 + // the query, so a future caller is covered too. Every read against a missing
934 + // entries table raises a DB error, and the daily send would raise one per
935 + // day counted on exactly the sites whose breakage we most need reported.
936 + if ( Register::is_entries_table_missing() ) {
937 + return 0;
938 + }
939 +
827 940 $start_date = $date . ' 00:00:00';
828 941 $end_date = $date . ' 23:59:59';
829 942
830 943 $where_conditions = [
@@ -852,12 +965,19 @@
852 965 * @since 2.5.1
853 966 * @return void
854 967 */
855 968 private function detect_state_events() {
856 - // plugin_activated: dedup in self::events()->track() ensures this fires only once.
857 - $bsf_referrers = get_option( 'bsf_product_referers', [] );
858 - $source = ! empty( $bsf_referrers['sureforms'] ) ? $bsf_referrers['sureforms'] : 'self';
859 - self::events()->track( 'plugin_activated', SRFM_VER, [ 'source' => $source ] );
969 + // plugin_activated: deferred to 'shutdown' so that a referring plugin/theme's
970 + // own BSF_UTM_Analytics::update_referer() call — which some products (incorrectly)
971 + // make only after their activate_plugin() call returns, in the same request — has
972 + // already run by the time we read bsf_product_referers. Reading this synchronously
973 + // here would race that write, since this constructor can execute mid-request while
974 + // SureForms itself is being activated by that other plugin.
975 + if ( did_action( 'shutdown' ) ) {
976 + $this->track_plugin_activated_event();
977 + } else {
978 + add_action( 'shutdown', [ $this, 'track_plugin_activated_event' ], PHP_INT_MAX );
979 + }
860 980
861 981 // One-time: re-send onboarding_completed with full properties (v2).
862 982 if ( ! Helper::get_srfm_option( 'onboarding_event_v2_flushed', false )
863 983 && \SRFM\Inc\Onboarding::get_instance()->get_onboarding_status() ) {
@@ -869,8 +989,16 @@
869 989 if ( \SRFM\Inc\Onboarding::get_instance()->get_onboarding_status() ) {
870 990 $onboarding_props = [];
871 991 $onboarding_analytics = Helper::get_srfm_option( 'onboarding_analytics', [] );
872 992
993 + // Which wizard produced the blob. Outside the non-empty guard and not
994 + // isset()-gated like the flags below, because absence IS the answer: a
995 + // blob from the old wizard, or no blob at all, must still report 'no'.
996 + // Without this the two wizards share one event name and can only be
997 + // told apart by which properties happen to be present -- and a v2 run
998 + // on Pro with no caching plugin emits none of the new ones.
999 + $onboarding_props['onboarding_v2'] = ! empty( $onboarding_analytics['onboardingV2'] ) ? 'yes' : 'no';
1000 +
873 1001 if ( ! empty( $onboarding_analytics ) && is_array( $onboarding_analytics ) ) {
874 1002 if ( ! empty( $onboarding_analytics['skippedSteps'] ) && is_array( $onboarding_analytics['skippedSteps'] ) ) {
875 1003 $onboarding_props['skipped_steps'] = implode( ',', $onboarding_analytics['skippedSteps'] );
876 1004 }
@@ -890,17 +1018,40 @@
890 1018 if ( isset( $onboarding_analytics['exitedEarly'] ) ) {
891 1019 $onboarding_props['exited_early'] = (bool) $onboarding_analytics['exitedEarly'] ? 'yes' : 'no';
892 1020 }
893 1021
894 - if ( ! empty( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) && is_array( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) ) {
895 - $premium = array_filter(
896 - $onboarding_analytics['premiumFeatures']['selectedFeatures'],
897 - static function( $f ) {
898 - return 'ai-form-generation' !== $f && 'entries' !== $f;
899 - }
1022 + // Add-ons step: the wizard shows one feature tab at a time
1023 + // instead of a checkbox list, so we report which tabs were opened and
1024 + // whether Upgrade was clicked. Blobs written by older wizards carry
1025 + // neither key and emit neither property.
1026 + if ( ! empty( $onboarding_analytics['premiumFeatures']['viewedTabs'] ) && is_array( $onboarding_analytics['premiumFeatures']['viewedTabs'] ) ) {
1027 + // The blob is whatever the wizard POSTed. The tabs are a closed
1028 + // set of four, so intersect against it rather than filtering on
1029 + // type: that drops anything unrecognised and caps both the
1030 + // content and the length of the property in one step.
1031 + $viewed_tabs = array_values(
1032 + array_intersect(
1033 + array_map( 'strval', array_filter( (array) $onboarding_analytics['premiumFeatures']['viewedTabs'], 'is_scalar' ) ),
1034 + [ 'multistep', 'conditional', 'calculation', 'conversational' ]
1035 + )
900 1036 );
901 - $onboarding_props['selected_premium_features'] = implode( ',', $premium );
902 - $onboarding_props['premium_features_count'] = (string) count( $premium );
1037 + $onboarding_props['viewed_premium_tabs'] = implode( ',', $viewed_tabs );
1038 + }
1039 +
1040 + if ( isset( $onboarding_analytics['premiumFeatures']['upgradeClicked'] ) ) {
1041 + $onboarding_props['premium_upgrade_clicked'] = (bool) $onboarding_analytics['premiumFeatures']['upgradeClicked'] ? 'yes' : 'no';
1042 + }
1043 +
1044 + // Cache-conflict step: shown only when a recognised caching plugin
1045 + // is active; "yes" means the user pressed "I've fixed this".
1046 + //
1047 + // Read this as an abandonment signal, not as intent. Acknowledging
1048 + // is the only way past the step, so everyone who completes the
1049 + // wizard reports "yes" and "no" only ever appears next to
1050 + // exited_early. It cannot answer "did people act on the warning" --
1051 + // only "did they stop here".
1052 + if ( isset( $onboarding_analytics['cacheConflictAcknowledged'] ) ) {
1053 + $onboarding_props['cache_conflict_acknowledged'] = (bool) $onboarding_analytics['cacheConflictAcknowledged'] ? 'yes' : 'no';
903 1054 }
904 1055 }
905 1056
906 1057 self::events()->track( 'onboarding_completed', '', $onboarding_props );