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
← All changes | includes/core/Tracking/class-wpfnl-telemetry.php +250 -0 3.13.2 → 3.13.3 View file →
@@ -78,8 +78,15 @@
78 78 return;
79 79 }
80 80
81 81 $this->register_triggers();
82 + $this->register_retention_events();
83 +
84 + /*
85 + * Product activity trail: every screen and REST action, sent daily for
86 + * opted-in sites and at deactivation for every site.
87 + */
88 + ActivityTrail::init( $this->client, self::POSTHOG_API_KEY );
82 89 }
83 90
84 91 /**
85 92 * Wire up all WordPress hooks for event tracking.
@@ -111,8 +118,47 @@
111 118 'vertical_canvas_mode' => [
112 119 'hook' => 'wpfunnels_vertical_mode_used',
113 120 'callback' => [ $this, 'feature_usage_payload' ],
114 121 ],
122 + // Retention signals — low-frequency admin or AI-driven actions, sent on every use.
123 + // template_funnel_imported — a funnel was built from the template library.
124 + 'template_funnel_imported' => [
125 + 'hook' => 'wpfunnels_after_funnel_imported',
126 + 'callback' => function ( $funnel_id = 0, $type = '', $builder = '' ) {
127 + return [
128 + 'template_type' => sanitize_key( (string) $type ),
129 + 'builder' => sanitize_key( (string) $builder ),
130 + ];
131 + },
132 + ],
133 + // ai_chat_started — the AI assistant was opened for real work.
134 + 'ai_chat_started' => [
135 + 'hook' => 'wpfunnels_ai_chat_started',
136 + 'callback' => function ( $conversation_id = 0, $user_id = 0, $context_type = '' ) {
137 + return [ 'context_type' => sanitize_key( (string) $context_type ) ];
138 + },
139 + ],
140 + // ai_funnel_published — a funnel built by the AI assistant went live.
141 + 'ai_funnel_published' => [
142 + 'hook' => 'wpfunnels_ai_funnel_published',
143 + 'callback' => function () {
144 + return [];
145 + },
146 + ],
147 + // mail_mint_automation_saved — email automation attached to a funnel.
148 + 'mail_mint_automation_saved' => [
149 + 'hook' => 'wpfunnels/save_mint_automation',
150 + 'callback' => function () {
151 + return [];
152 + },
153 + ],
154 + // step_duplicated — a step was duplicated on the canvas.
155 + 'step_duplicated' => [
156 + 'hook' => 'wpfunnels/after_step_duplicate',
157 + 'callback' => function () {
158 + return [];
159 + },
160 + ],
115 161 ],
116 162 ] );
117 163 } catch ( \Exception $e ) {
118 164 // Failure-safe: trigger registration errors must not surface to callers.
@@ -150,8 +196,212 @@
150 196 // Instead we scan $wp_filter at priority 0 and identify WPFunnels' Deactivation
151 197 // instance by the plugin_action_links hook it registers for WPFNL_FILE.
152 198 add_action( 'admin_footer', [ $this, 'remove_sdk_deactivation_modal' ], 0 );
153 199 add_action( 'admin_footer', [ $this, 'render_deactivation_modal' ] );
200 + }
201 +
202 + // -------------------------------------------------------------------------
203 + // Retention: weekly "value delivered" signals
204 + // -------------------------------------------------------------------------
205 +
206 + /**
207 + * Is WPFunnels producing results on this site this week?
208 + *
209 + * Every hook below fires on visitor or cron traffic (checkouts, offers,
210 + * opt-ins), so each feature is sent at most once a week per site through
211 + * the weekly gate. Properties are non-personal: step or offer type and a
212 + * value bucket — never order, customer or funnel ids.
213 + *
214 + * Hooks were chosen by their call sites, not their names. Left out on
215 + * purpose: `wpfunnels/update_ab_testing_winner` (fires on every Pro step
216 + * visit, not when a winner is chosen), `wpfunnels/ab_testing_auto_end`
217 + * (fires when the auto-end setting is saved) and `wpfunnels/trigger_automation`
218 + * (fires at the end of every journey whether or not an automation exists).
219 + */
220 + private function register_retention_events(): void {
221 + // funnel_order_placed — a WooCommerce order was placed through a funnel checkout.
222 + add_action(
223 + 'wpfunnels/funnel_order_placed',
224 + function ( $order_id = 0 ) {
225 + $this->send_weekly_feature_used(
226 + 'funnel_order_placed',
227 + function () use ( $order_id ) {
228 + $order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : null;
229 + return [ 'value_bucket' => $order ? self::value_bucket( $order->get_total() ) : 'unknown' ];
230 + }
231 + );
232 + },
233 + 10,
234 + 1
235 + );
236 +
237 + // order_bump_accepted — a shopper ticked an order bump at checkout (before the order).
238 + add_action(
239 + 'wpfunnels/order_bump_accepted',
240 + function () {
241 + $this->send_weekly_feature_used( 'order_bump_accepted' );
242 + }
243 + );
244 +
245 + // offer_accepted / offer_rejected — an upsell or downsell was answered.
246 + foreach ( [ 'wpfunnels/offer_accepted' => 'offer_accepted', 'wpfunnels/offer_rejected' => 'offer_rejected' ] as $hook => $feature ) {
247 + add_action(
248 + $hook,
249 + function ( $order = null, $offer_product = [] ) use ( $feature ) {
250 + $this->send_weekly_feature_used(
251 + $feature,
252 + function () use ( $offer_product ) {
253 + $step_id = is_array( $offer_product ) && isset( $offer_product['step_id'] ) ? absint( $offer_product['step_id'] ) : 0;
254 + $properties = [ 'offer_type' => $step_id ? sanitize_key( (string) get_post_meta( $step_id, '_step_type', true ) ) : 'unknown' ];
255 + if ( is_array( $offer_product ) && isset( $offer_product['total'] ) && is_numeric( $offer_product['total'] ) ) {
256 + $properties['value_bucket'] = self::value_bucket( $offer_product['total'] );
257 + }
258 + return $properties;
259 + }
260 + );
261 + },
262 + 10,
263 + 2
264 + );
265 + }
266 +
267 + // optin_submitted — a visitor submitted a funnel opt-in form.
268 + add_action(
269 + 'wpfunnels/after_optin_submit',
270 + function ( $step_id = 0, $post_action = '' ) {
271 + $this->send_weekly_feature_used( 'optin_submitted', [ 'post_action' => sanitize_key( (string) $post_action ) ] );
272 + },
273 + 10,
274 + 2
275 + );
276 +
277 + // funnel_visited / funnel_completed — a visitor entered a funnel's first
278 + // step or reached its last one. Admins previewing their own funnels are skipped.
279 + foreach ( [ 'wpfunnels/funnel_journey_starts' => 'funnel_visited', 'wpfunnels/funnel_journey_end' => 'funnel_completed' ] as $hook => $feature ) {
280 + add_action(
281 + $hook,
282 + function () use ( $feature ) {
283 + if ( current_user_can( 'wpf_manage_funnels' ) ) {
284 + return;
285 + }
286 + $this->send_weekly_feature_used( $feature );
287 + }
288 + );
289 + }
290 +
291 + // checkout_visit_recorded — a unique visitor reached a funnel or store checkout.
292 + add_action(
293 + 'wpfunnels/checkout_visit_recorded',
294 + function ( $type = '' ) {
295 + $this->send_weekly_feature_used( 'checkout_visit_recorded', [ 'checkout_type' => sanitize_key( (string) $type ) ] );
296 + }
297 + );
298 +
299 + // offer_subscription_created — an offer created a WooCommerce subscription (Pro).
300 + add_action(
301 + 'wpfunnels/subscription_created',
302 + function () {
303 + $this->send_weekly_feature_used( 'offer_subscription_created' );
304 + }
305 + );
306 + }
307 +
308 + /**
309 + * Send a `feature_used` event at most once a week per site.
310 + *
311 + * These hooks sit on hot visitor paths, so the two cheap checks come first:
312 + * not opted in (track_feature_used() would drop the event anyway) and
313 + * already sent this week. Only then does claim_weekly_slot() take the
314 + * atomic slot. Properties may be a callable, evaluated only when the event
315 + * is actually sent.
316 + *
317 + * @param string $feature Feature name.
318 + * @param array|callable $properties Event properties, or a callable returning them.
319 + * @return void
320 + */
321 + public function send_weekly_feature_used( string $feature, $properties = [] ): void {
322 + try {
323 + if ( ! $this->client || 'yes' !== $this->client->get_optin_state() ) {
324 + return;
325 + }
326 +
327 + if ( ! self::claim_weekly_slot( 'wpfunnels_telemetry_' . $feature . '_last_sent' ) ) {
328 + return;
329 + }
330 +
331 + $properties = is_callable( $properties ) ? call_user_func( $properties ) : $properties;
332 + $this->client->track_feature_used( $feature, is_array( $properties ) ? $properties : [] );
333 + } catch ( \Throwable $e ) {
334 + // Telemetry must never break a checkout or an offer.
335 + }
336 + }
337 +
338 + /**
339 + * Claim this week's send slot for a gate option.
340 + *
341 + * Fast path: already sent this week — skips the write for almost every
342 + * call; a stale read just falls through to the atomic claim. The claim is a
343 + * conditional UPDATE, so only one of several concurrent requests can flip
344 + * the value; the rest affect 0 rows.
345 + *
346 + * @param string $option_key Gate option storing the last-sent time.
347 + * @param int|null $now Current time (for tests).
348 + * @return bool True when this call won the slot.
349 + */
350 + public static function claim_weekly_slot( string $option_key, ?int $now = null ): bool {
351 + global $wpdb;
352 +
353 + $now = null === $now ? time() : $now;
354 + $threshold = $now - WEEK_IN_SECONDS;
355 +
356 + if ( (int) get_option( $option_key, 0 ) >= $threshold ) {
357 + return false;
358 + }
359 +
360 + // Ensure the row exists so the conditional UPDATE has something to claim.
361 + // add_option is a no-op if it already exists, preserving the last send time.
362 + add_option( $option_key, '0', '', false );
363 +
364 + $claimed = $wpdb->query(
365 + $wpdb->prepare(
366 + "UPDATE {$wpdb->options} SET option_value = %d WHERE option_name = %s AND option_value < %d",
367 + $now,
368 + $option_key,
369 + $threshold
370 + )
371 + ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic claim; the options API check-then-set is racy under concurrency.
372 +
373 + if ( empty( $claimed ) ) {
374 + return false;
375 + }
376 +
377 + // Keep the options cache in sync with the value just written directly.
378 + wp_cache_delete( $option_key, 'options' );
379 +
380 + return true;
381 + }
382 +
383 + /**
384 + * Bucket a money amount so no exact order value is sent. Store currency
385 + * is not converted; the buckets only tell small from large orders.
386 + *
387 + * @param mixed $amount Amount.
388 + * @return string
389 + */
390 + public static function value_bucket( $amount ): string {
391 + $amount = (float) $amount;
392 +
393 + if ( $amount <= 0 ) {
394 + return '0';
395 + }
396 +
397 + foreach ( [ 50 => '1-49', 100 => '50-99', 250 => '100-249', 500 => '250-499', 1000 => '500-999' ] as $limit => $bucket ) {
398 + if ( $amount < $limit ) {
399 + return $bucket;
400 + }
401 + }
402 +
403 + return '1000+';
154 404 }
155 405
156 406 // -------------------------------------------------------------------------
157 407 // Public API — called from REST controllers