PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
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 0.0.2 All 97 releases
← All changes | admin/analytics.php +293 -110 2.7.02.12.7 View file →
@@ -6,10 +6,13 @@
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;
14 +use SRFM\Inc\Learn;
12 15 use SRFM\Inc\Traits\Get_Instance;
13 16
14 17 if ( ! defined( 'ABSPATH' ) ) {
15 18 exit; // Exit if accessed directly.
@@ -42,10 +45,10 @@
42 45 if ( ! class_exists( 'BSF_Analytics_Loader' ) ) {
43 46 require_once SRFM_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-loader.php';
44 47 }
45 48
46 - if ( ! class_exists( 'Astra_Notices' ) ) {
47 - require_once SRFM_DIR . 'inc/lib/astra-notices/class-astra-notices.php';
49 + if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
50 + require_once SRFM_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
48 51 }
49 52
50 53 add_filter(
51 54 'uds_survey_allowed_screens',
@@ -127,9 +130,9 @@
127 130 * @since 1.4.0
128 131 * @return array
129 132 */
130 133 public function add_srfm_analytics_data( $stats_data ) {
131 - $stats_data['plugin_data']['sureforms'] = [
134 + $stats_data['plugin_data']['sureforms'] = [
132 135 'free_version' => SRFM_VER,
133 136 'site_language' => get_locale(),
134 137 'most_used_anti_spam' => $this->most_used_anti_spam(),
135 138 'user_status' => $this->user_status(),
@@ -134,8 +137,12 @@
134 137 'most_used_anti_spam' => $this->most_used_anti_spam(),
135 138 'user_status' => $this->user_status(),
136 139 'pointer_popup_clicked' => $this->pointer_popup_clicked(),
137 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 +
138 145 $stats_data['plugin_data']['sureforms']['numeric_values'] = [
139 146 'total_forms' => wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0,
140 147 'instant_forms_enabled' => $this->instant_forms_enabled(),
141 148 'forms_using_custom_css' => $this->forms_using_custom_css(),
@@ -141,19 +148,23 @@
141 148 'forms_using_custom_css' => $this->forms_using_custom_css(),
142 149 'ai_generated_forms' => $this->ai_generated_forms(),
143 150 'ai_generated_payment_forms' => $this->ai_generated_forms( 'payments' ),
144 151 'payment_forms' => $this->get_payment_forms_count(),
145 - 'total_entries' => Entries::get_total_entries_by_status(),
152 + 'total_entries' => $entries_table_missing ? 0 : Entries::get_total_entries_by_status(),
146 153 'restricted_forms' => $this->get_restricted_forms(),
147 154 'embed_styling_gb_default' => self::embed_styling_gutenberg_count( 'default' ),
148 155 'embed_styling_el_default' => self::embed_styling_elementor_count( 'default' ),
149 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(),
150 159 ];
151 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 +
152 166 $stats_data['plugin_data']['sureforms'] = array_merge_recursive( $stats_data['plugin_data']['sureforms'], $this->global_settings_data() );
153 - // Add onboarding analytics data.
154 - $stats_data['plugin_data']['sureforms'] = array_merge_recursive( $stats_data['plugin_data']['sureforms'], $this->onboarding_analytics_data() );
155 -
156 167 // Add KPI tracking data.
157 168 $kpi_data = $this->get_kpi_tracking_data();
158 169 if ( ! empty( $kpi_data ) ) {
159 170 $stats_data['plugin_data']['sureforms']['kpi_records'] = $kpi_data;
@@ -158,8 +169,11 @@
158 169 if ( ! empty( $kpi_data ) ) {
159 170 $stats_data['plugin_data']['sureforms']['kpi_records'] = $kpi_data;
160 171 }
161 172
173 + // Build learn progress snapshot.
174 + $this->get_learn_tracking_data();
175 +
162 176 // Flush pending events into payload (only if any exist).
163 177 $pending_events = self::events()->flush_pending();
164 178 if ( ! empty( $pending_events ) ) {
165 179 $stats_data['plugin_data']['sureforms']['events_record'] = $pending_events;
@@ -486,111 +500,85 @@
486 500
487 501 // Payment analytics - check if any payment method is enabled.
488 502 $global_data['boolean_values']['stripe_enabled'] = $this->is_stripe_enabled();
489 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 +
490 510 return $global_data;
491 511 }
492 512
493 513 /**
494 - * Generates onboarding analytics data
514 + * Total page views counted across all published forms.
495 515 *
496 - * @since 1.9.1
497 - * @return array
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
498 532 */
499 - public function onboarding_analytics_data() {
500 - $onboarding_data = [];
501 - $analytics_option = Helper::get_srfm_option( 'onboarding_analytics', [] );
533 + public function total_form_views() {
534 + global $wpdb;
502 535
503 - if ( empty( $analytics_option ) ) {
504 - return $onboarding_data;
505 - }
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 + );
506 556
507 - // Process skipped steps - store as an array.
508 - if ( ! empty( $analytics_option['skippedSteps'] ) && is_array( $analytics_option['skippedSteps'] ) ) {
509 - // Map step keys to more descriptive names.
510 - $step_mapping = [
511 - 'welcome' => 'Welcome',
512 - 'connect' => 'Connect',
513 - 'emailDelivery' => 'SureMail',
514 - 'premiumFeatures' => 'Features',
515 - 'done' => 'Done',
516 - ];
557 + return Helper::get_integer_value( $total );
558 + }
517 559
518 - // Transform the step keys to their descriptive names.
519 - $mapped_steps = array_map(
520 - static function( $step ) use ( $step_mapping ) {
521 - return $step_mapping[ $step ] ?? $step;
522 - },
523 - $analytics_option['skippedSteps']
524 - );
525 -
526 - // Store as an array.
527 - $onboarding_data['onboarding_skipped_steps'] = $mapped_steps;
528 - }
529 -
530 - // SureMail Installation Status.
531 - if ( isset( $analytics_option['suremailInstalled'] ) ) {
532 - $onboarding_data['boolean_values']['onboarding_suremail_installed'] = (bool) $analytics_option['suremailInstalled'];
533 - }
534 -
535 - // Account Connection Status.
536 - if ( isset( $analytics_option['accountConnected'] ) ) {
537 - $onboarding_data['boolean_values']['onboarding_account_connected'] = (bool) $analytics_option['accountConnected'];
538 - }
539 -
540 - // Onboarding Completion Status.
541 - if ( isset( $analytics_option['completed'] ) ) {
542 - $onboarding_data['boolean_values']['onboarding_completed'] = (bool) $analytics_option['completed'];
543 - }
544 -
545 - // Onboarding Early Exit Status.
546 - if ( isset( $analytics_option['exitedEarly'] ) ) {
547 - $onboarding_data['boolean_values']['onboarding_exited_early'] = (bool) $analytics_option['exitedEarly'];
548 - }
549 -
550 - // Onboarding Selected Premium Features.
551 - if ( ! empty( $analytics_option['premiumFeatures'] ) && ! empty( $analytics_option['premiumFeatures']['selectedFeatures'] ) ) {
552 - // Map feature IDs to more descriptive names - exclude free features.
553 - $feature_mapping = [
554 - // Starter features.
555 - 'multi_step_form' => 'Multi-step Forms',
556 - 'conditional_logic' => 'Conditional Fields',
557 - 'webhooks' => 'Webhooks',
558 - 'advanced_fields' => 'Advanced Fields',
559 -
560 - // Pro features.
561 - 'conversational_forms' => 'Conversational Forms',
562 - 'digital_signatures' => 'Digital Signatures',
563 -
564 - // Business features.
565 - 'calculations' => 'Calculators',
566 - 'user_registration' => 'User Registration and Login',
567 - 'custom_app' => 'Custom App',
568 - 'pdf_generation' => 'PDF Generation',
569 - ];
570 -
571 - // Filter out any free features that might have been included.
572 - $premium_features = array_filter(
573 - $analytics_option['premiumFeatures']['selectedFeatures'],
574 - static function( $feature ) {
575 - // Exclude free features (ai-form-generation and entries).
576 - return 'ai-form-generation' !== $feature && 'entries' !== $feature;
577 - }
578 - );
579 -
580 - // Transform the feature IDs to their descriptive names.
581 - $mapped_features = array_map(
582 - static function( $feature ) use ( $feature_mapping ) {
583 - return $feature_mapping[ $feature ] ?? $feature;
584 - },
585 - $premium_features
586 - );
587 -
588 - // Store as an array.
589 - $onboarding_data['onboarding_selected_premium_features'] = $mapped_features;
590 - }
591 -
592 - return $onboarding_data;
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 + );
593 581 }
594 582
595 583 /**
596 584 * Returns user status.
@@ -713,9 +701,9 @@
713 701 }
714 702 }
715 703
716 704 /**
717 - * Track first time a form is published.
705 + * Track first time a form is published (activation event).
718 706 *
719 707 * @param string $new_status New post status.
720 708 * @param string $old_status Old post status.
721 709 * @param \WP_Post $post Post object.
@@ -729,14 +717,22 @@
729 717
730 718 $is_ai = ! empty( get_post_meta( $post->ID, '_srfm_is_ai_generated', true ) );
731 719 $block_count = substr_count( $post->post_content, '<!-- wp:srfm/' );
732 720
721 + // Time-to-value: days between install and first form published.
722 + $install_time = get_site_option( 'sureforms_usage_installed_time', 0 );
723 + $days_since_install = 0;
724 + if ( $install_time > 0 ) {
725 + $days_since_install = (int) floor( ( time() - $install_time ) / DAY_IN_SECONDS );
726 + }
727 +
733 728 self::events()->track(
734 729 'first_form_published',
735 730 (string) $post->ID,
736 731 [
737 - 'is_ai_generated' => $is_ai,
738 - 'block_count' => $block_count,
732 + 'is_ai_generated' => (string) (int) $is_ai,
733 + 'block_count' => (string) $block_count,
734 + 'days_since_install' => (string) $days_since_install,
739 735 ]
740 736 );
741 737 }
742 738
@@ -812,8 +808,24 @@
812 808 );
813 809 }
814 810
815 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 + /**
816 828 * Extract non-inherit formTheme values from Bricks element data.
817 829 *
818 830 * Recursively walks the unserialized Bricks elements array looking for
819 831 * sureforms elements with custom formTheme settings.
@@ -916,8 +928,16 @@
916 928 * @since 2.4.0
917 929 * @return int Daily submissions count.
918 930 */
919 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 +
920 940 $start_date = $date . ' 00:00:00';
921 941 $end_date = $date . ' 23:59:59';
922 942
923 943 $where_conditions = [
@@ -945,16 +965,66 @@
945 965 * @since 2.5.1
946 966 * @return void
947 967 */
948 968 private function detect_state_events() {
949 - // plugin_activated: dedup in self::events()->track() ensures this fires only once.
950 - $bsf_referrers = get_option( 'bsf_product_referers', [] );
951 - $source = ! empty( $bsf_referrers['sureforms'] ) ? $bsf_referrers['sureforms'] : 'self';
952 - 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 + }
953 980
954 - // onboarding_completed: detect completed state.
981 + // One-time: re-send onboarding_completed with full properties (v2).
982 + if ( ! Helper::get_srfm_option( 'onboarding_event_v2_flushed', false )
983 + && \SRFM\Inc\Onboarding::get_instance()->get_onboarding_status() ) {
984 + self::events()->flush_pushed( [ 'onboarding_completed' ] );
985 + Helper::update_srfm_option( 'onboarding_event_v2_flushed', true );
986 + }
987 +
988 + // onboarding_completed: detect completed state with full onboarding details.
955 989 if ( \SRFM\Inc\Onboarding::get_instance()->get_onboarding_status() ) {
956 - self::events()->track( 'onboarding_completed' );
990 + $onboarding_props = [];
991 + $onboarding_analytics = Helper::get_srfm_option( 'onboarding_analytics', [] );
992 +
993 + if ( ! empty( $onboarding_analytics ) && is_array( $onboarding_analytics ) ) {
994 + if ( ! empty( $onboarding_analytics['skippedSteps'] ) && is_array( $onboarding_analytics['skippedSteps'] ) ) {
995 + $onboarding_props['skipped_steps'] = implode( ',', $onboarding_analytics['skippedSteps'] );
996 + }
997 +
998 + if ( isset( $onboarding_analytics['suremailInstalled'] ) ) {
999 + $onboarding_props['suremail_installed'] = (bool) $onboarding_analytics['suremailInstalled'] ? 'yes' : 'no';
1000 + }
1001 +
1002 + if ( isset( $onboarding_analytics['accountConnected'] ) ) {
1003 + $onboarding_props['account_connected'] = (bool) $onboarding_analytics['accountConnected'] ? 'yes' : 'no';
1004 + }
1005 +
1006 + if ( isset( $onboarding_analytics['completed'] ) ) {
1007 + $onboarding_props['completed'] = (bool) $onboarding_analytics['completed'] ? 'yes' : 'no';
1008 + }
1009 +
1010 + if ( isset( $onboarding_analytics['exitedEarly'] ) ) {
1011 + $onboarding_props['exited_early'] = (bool) $onboarding_analytics['exitedEarly'] ? 'yes' : 'no';
1012 + }
1013 +
1014 + if ( ! empty( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) && is_array( $onboarding_analytics['premiumFeatures']['selectedFeatures'] ) ) {
1015 + $premium = array_filter(
1016 + $onboarding_analytics['premiumFeatures']['selectedFeatures'],
1017 + static function( $f ) {
1018 + return 'ai-form-generation' !== $f && 'entries' !== $f;
1019 + }
1020 + );
1021 + $onboarding_props['selected_premium_features'] = implode( ',', $premium );
1022 + $onboarding_props['premium_features_count'] = (string) count( $premium );
1023 + }
1024 + }
1025 +
1026 + self::events()->track( 'onboarding_completed', '', $onboarding_props );
957 1027 }
958 1028
959 1029 // stripe_connected: detect connection state.
960 1030 if ( class_exists( '\SRFM\Inc\Payments\Stripe\Stripe_Helper' )
@@ -962,8 +1032,31 @@
962 1032 $mode = \SRFM\Inc\Payments\Stripe\Stripe_Helper::get_stripe_mode();
963 1033 self::events()->track( 'stripe_connected', ! empty( $mode ) ? $mode : 'live' );
964 1034 }
965 1035
1036 + // first_ai_form_generated: detect if any AI-generated form exists.
1037 + // Guard with is_tracked() to skip the meta_query after the event is already tracked.
1038 + if ( ! self::events()->is_tracked( 'first_ai_form_generated' ) ) {
1039 + $ai_forms = get_posts(
1040 + [
1041 + 'post_type' => SRFM_FORMS_POST_TYPE,
1042 + 'post_status' => 'any',
1043 + 'posts_per_page' => 1,
1044 + 'fields' => 'ids',
1045 + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Runs once per lifecycle via is_tracked guard.
1046 + [
1047 + 'key' => '_srfm_is_ai_generated',
1048 + 'value' => '',
1049 + 'compare' => '!=',
1050 + ],
1051 + ],
1052 + ]
1053 + );
1054 + if ( ! empty( $ai_forms ) ) {
1055 + self::events()->track( 'first_ai_form_generated' );
1056 + }
1057 + }
1058 +
966 1059 // MCP / Abilities API first-enable events.
967 1060 $mcp_settings = get_option( 'srfm_mcp_settings_options', [] );
968 1061
969 1062 if ( ! empty( $mcp_settings['srfm_abilities_api'] ) ) {
@@ -972,7 +1065,97 @@
972 1065
973 1066 if ( ! empty( $mcp_settings['srfm_mcp_server'] ) ) {
974 1067 self::events()->track( 'mcp_server_enabled' );
975 1068 }
1069 + }
1070 +
1071 + /**
1072 + * Get learn section progress aggregated across all users.
1073 + *
1074 + * Queries all users with srfm_learn_progress meta, counts completions
1075 + * per step. Called at analytics send time (bsf_core_stats filter).
1076 + *
1077 + * @since 2.8.0
1078 + * @return void
1079 + */
1080 + private function get_learn_tracking_data() {
1081 + // Only send when progress changed or event has never been tracked.
1082 + $has_changed = get_transient( 'srfm_learn_progress_changed' );
1083 + if ( ! $has_changed && self::events()->is_tracked( 'learn' ) ) {
1084 + return;
1085 + }
1086 +
1087 + $chapters = Learn::get_chapters_structure();
1088 + $step_counts = [];
1089 + $total_steps = 0;
1090 +
1091 + // Initialize step counters from canonical structure.
1092 + foreach ( $chapters as $chapter ) {
1093 + if ( ! isset( $chapter['id'], $chapter['steps'] ) || ! is_array( $chapter['steps'] ) ) {
1094 + continue;
1095 + }
1096 + foreach ( $chapter['steps'] as $step ) {
1097 + if ( ! isset( $step['id'] ) ) {
1098 + continue;
1099 + }
1100 + $step_counts[ $chapter['id'] . '/' . $step['id'] ] = 0;
1101 + ++$total_steps;
1102 + }
1103 + }
1104 +
1105 + // Query all users who have learn progress.
1106 + $users = get_users(
1107 + [
1108 + 'meta_key' => 'srfm_learn_progress', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Runs once per analytics send cycle; bounded by admin user count.
1109 + 'fields' => 'ID',
1110 + ]
1111 + );
1112 +
1113 + $users_with_progress = 0;
1114 + $users_fully_completed = 0;
1115 + $max_completed = 0;
1116 +
1117 + foreach ( $users as $user_id ) {
1118 + $progress = get_user_meta( $user_id, 'srfm_learn_progress', true );
1119 + if ( ! is_array( $progress ) || empty( $progress ) ) {
1120 + continue;
1121 + }
1122 +
1123 + $user_completed = 0;
1124 +
1125 + foreach ( $step_counts as $key => $count ) {
1126 + [ $chapter_id, $step_id ] = explode( '/', $key );
1127 + if ( ! empty( $progress[ $chapter_id ][ $step_id ] ) ) {
1128 + $step_counts[ $key ] = $count + 1;
1129 + ++$user_completed;
1130 + }
1131 + }
1132 +
1133 + if ( $user_completed > 0 ) {
1134 + ++$users_with_progress;
1135 + }
1136 + if ( $user_completed === $total_steps ) {
1137 + ++$users_fully_completed;
1138 + }
1139 + $max_completed = max( $max_completed, $user_completed );
1140 + }
1141 +
1142 + // Build flat properties — step keys use snake_case (hyphens → underscores).
1143 + $properties = [];
1144 + foreach ( $step_counts as $key => $count ) {
1145 + $step_id = explode( '/', $key )[1];
1146 + $properties[ str_replace( '-', '_', $step_id ) ] = (string) $count;
1147 + }
1148 +
1149 + $properties['users_with_progress'] = (string) $users_with_progress;
1150 + $properties['users_fully_completed'] = (string) $users_fully_completed;
1151 + $properties['total_steps'] = (string) $total_steps;
1152 +
1153 + // Flush dedup so event re-tracks with latest snapshot.
1154 + self::events()->flush_pushed( [ 'learn' ] );
1155 + self::events()->track( 'learn', (string) $max_completed, $properties );
1156 +
1157 + // Clear the change flag after tracking.
1158 + delete_transient( 'srfm_learn_progress_changed' );
976 1159 }
977 1160
978 1161 }