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 / includes / core / Tracking / class-wpfnl-activity-trail.php

class-wpfnl-activity-trail.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.3, at includes/core/Tracking/class-wpfnl-activity-trail.php

1,484 lines 47.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Product activity trail.
4 *
5 * Records every WPFunnels feature interaction on the site:
6 * - every admin screen opened: routes of the React admin (sent from the app
7 * as route patterns) and the server-rendered pages (edit funnel canvas,
8 * email builder, integrations, setup wizard), and
9 * - every WPFunnels REST action (all create/update/delete/import/test calls in
10 * Free and Pro, plus any request that fails), named automatically from the
11 * route pattern, e.g. `POST /funnels/:funnel_id/duplicate` becomes
12 * `funnel_duplicated`, and a failure becomes `funnel_duplicated_failed`.
13 *
14 * Delivery to PostHog, one event per entry with its original timestamp and the
15 * same distinct id the Linno Telemetry SDK uses:
16 * - opted-in sites: sent daily, so retained and churned users can be compared.
17 * - every site: whatever is unsent is sent when the plugin is deactivated,
18 * followed by a `deactivation_context` summary.
19 *
20 * Payload is anonymous: route patterns (never ids), feature names, HTTP status
21 * and error codes. No request bodies, messages, emails, orders or customer data.
22 *
23 * @package WPFunnels\Tracking
24 * @since 3.14.0
25 */
26
27 namespace WPFunnels\Tracking;
28
29 use LinnoSDK\Telemetry\Helpers\Utils;
30 use PostHog\PostHog;
31
32 /**
33 * Class ActivityTrail
34 *
35 * @since 3.14.0
36 */
37 class ActivityTrail {
38
39 /**
40 * Option that stores the trail (not autoloaded).
41 */
42 const OPTION = 'wpfnl_activity_trail';
43
44 /**
45 * Maximum unsent entries kept (a busy day on an opted-in site).
46 */
47 const MAX_UNSENT = 200;
48
49 /**
50 * Already-sent entries kept for the deactivation summary.
51 */
52 const KEEP_SENT = 30;
53
54 /**
55 * Entries older than this are dropped (30 days).
56 */
57 const MAX_AGE = 2592000;
58
59 /**
60 * Repeats of the same activity within this window collapse into one entry
61 * (canvas autosave, reopening a screen).
62 */
63 const COLLAPSE_WINDOW = 600;
64
65 /**
66 * WP-Cron hook for the daily flush on opted-in sites.
67 */
68 const FLUSH_HOOK = 'wpfnl_activity_trail_flush';
69
70 /**
71 * PostHog ingestion host (same project as the Linno SDK client).
72 */
73 const POSTHOG_HOST = 'https://eu.i.posthog.com';
74
75 /**
76 * PostHog event name prefix for trail events.
77 */
78 const EVENT_PREFIX = 'wpfnl_activity/';
79
80 /**
81 * Slug the Linno SDK client is built with; names its deactivation transient.
82 */
83 const SDK_SLUG = 'wpfunnels';
84
85 /**
86 * Admin REST namespaces of WPFunnels Free and Pro (Pro registers its routes
87 * under `wpfunnels/v1` too). The MCP server namespace (`wpfunnels`) is left
88 * out: MCP traffic is recorded through the MCP observability handler.
89 */
90 const NAMESPACES = array( 'wpfunnels/v1', 'wpfnl/v1' );
91
92 /**
93 * Path rewrites per namespace, applied before naming. `wpfnl/v1` only hosts
94 * the automation canvas controller — the feature `wpfunnels/v1/automation-canvas`
95 * also serves — while `wpfunnels/v1/automation` is the Pro CRM automations.
96 */
97 const NAMESPACE_PATHS = array(
98 'wpfnl/v1' => array( '/automation' => '/automation-canvas' ),
99 );
100
101 /**
102 * Routes (without namespace) that are never recorded.
103 */
104 const IGNORED_ROUTES = array( '/activity-trail/screen' );
105
106 /**
107 * First route segment → feature area.
108 */
109 const AREA_ALIASES = array(
110 'funnels' => 'funnel',
111 'funnel-control' => 'funnel',
112 'steps' => 'step',
113 'templates' => 'template',
114 'remote_funnel' => 'template',
115 'order-bump' => 'order_bump',
116 'products' => 'product',
117 'getProducts' => 'product',
118 'updateSelectedProduct' => 'product',
119 'delete-product' => 'product',
120 'calculateDiscountPrice' => 'discount',
121 'calculateMainProductsDiscount' => 'discount',
122 'getFluentForms' => 'optin',
123 'getOtherSteps' => 'step',
124 'import-export' => 'import_export',
125 'report' => 'dashboard',
126 'setup-wizard' => 'setup_wizard',
127 'store-checkout' => 'store_checkout',
128 'automation' => 'crm_automation',
129 'automation-canvas' => 'automation',
130 'mint' => 'mail_mint_automation',
131 'abtesting' => 'ab_test',
132 'global-funnel' => 'global_funnel',
133 'activate-plugin' => 'plugin',
134 );
135
136 /**
137 * Route words that name the verb of the action, and the past tense the
138 * event uses. `get` carries no meaning in an event name and is dropped.
139 */
140 const VERBS = array(
141 'create' => 'created',
142 'save' => 'saved',
143 'update' => 'updated',
144 'delete' => 'deleted',
145 'add' => 'added',
146 'duplicate' => 'duplicated',
147 'import' => 'imported',
148 'export' => 'exported',
149 'restore' => 'restored',
150 'reset' => 'reset',
151 'remove' => 'removed',
152 'paste' => 'pasted',
153 'change' => 'changed',
154 'declare' => 'declared',
155 'stop' => 'stopped',
156 'archive' => 'archived',
157 'clear' => 'cleared',
158 'sync' => 'synced',
159 'activate' => 'activated',
160 'dismiss' => 'dismissed',
161 'trash' => 'trashed',
162 'convert' => 'converted',
163 'generate' => 'generated',
164 'regenerate' => 'regenerated',
165 'confirm' => 'confirmed',
166 'finish' => 'finished',
167 'get' => '',
168 );
169
170 /**
171 * Words that add nothing to an event name (plugin prefixes).
172 */
173 const NOISE_WORDS = array( 'wpfnl', 'wpfunnel', 'wpfunnels' );
174
175 /**
176 * Explicit names for actions the naming rules can't infer (actions exposed
177 * as GET, typos in routes, or clearer names for key signals).
178 * Keys are "<GET|WRITE|DELETE> <normalized route>".
179 */
180 const OVERRIDES = array(
181 'GET /funnel-control/exportFunnel' => 'funnel_exported',
182 'GET /funnels/:funnel_id/export' => 'funnel_exported',
183 'GET /webhook/delete-webhook/:funnel_id' => 'webhook_deleted',
184 'GET /webhook/update-webhook-status/:funnel_id' => 'webhook_status_updated',
185 'GET /updateSelectedProduct' => 'product_selected',
186 'WRITE /funnel-control/saveFunnel' => 'funnel_canvas_saved',
187 'WRITE /funnel-control/steps' => 'funnel_steps_saved',
188 'WRITE /funnel-control/wpfnl-get-funnel-settings' => 'funnel_settings_saved',
189 'WRITE /funnel-control/:funnel_id/funnel-name-change' => 'funnel_renamed',
190 'WRITE /funnels/:funnel_id/status' => 'funnel_status_updated',
191 'WRITE /funnels/bulk' => 'funnel_bulk_action',
192 'WRITE /funnels/:funnel_id/steps/:step_id/metadata' => 'step_metadata_updated',
193 'WRITE /order-bump' => 'order_bump_saved',
194 'WRITE /offer/saveUpsellData' => 'upsell_saved',
195 'WRITE /offer/saveDownsellData' => 'downsell_saved',
196 'WRITE /steps/wpfunnel-import-step' => 'step_template_imported',
197 'WRITE /steps/update-conditional-status' => 'step_condition_status_updated',
198 'WRITE /templates/import-funnel' => 'template_import_started',
199 'WRITE /import-export/import' => 'funnel_import_started',
200 'WRITE /import-export/import-reset' => 'funnel_import_reset',
201 'WRITE /funnel-control/:funnel_id/convert-to-store-checkout' => 'funnel_converted_to_store_checkout',
202 'WRITE /import-export/bulk-export-funnel' => 'funnel_bulk_exported',
203 'WRITE /gutenberg' => 'gutenberg_block_rendered',
204 'WRITE /settings/:group_id/:settings_id' => 'settings_saved',
205 'WRITE /settings/activate_wc_plugins' => 'woocommerce_activated',
206 'WRITE /settings/activate_mrm_plugins' => 'mail_mint_activated',
207 'WRITE /settings/activate-plugins' => 'plugins_activated',
208 'WRITE /settings/create-contact' => 'setup_wizard_contact_created',
209 'WRITE /settings/save-setup-wizard-settings' => 'setup_wizard_settings_saved',
210 'WRITE /settings/update-builder-type' => 'page_builder_changed',
211 'WRITE /settings/canvas-view-mode' => 'canvas_view_mode_changed',
212 'WRITE /settings/test-notification' => 'test_notification_sent',
213 'WRITE /settings/mcp/app-passwords' => 'mcp_app_password_created',
214 'DELETE /settings/mcp/app-passwords/:uuid' => 'mcp_app_password_deleted',
215 'DELETE /settings/logs' => 'logs_deleted',
216 'WRITE /setup-wizard/track-step' => 'setup_wizard_step_viewed',
217 'WRITE /setup-wizard/complete-step' => 'setup_wizard_step_completed',
218 'WRITE /report/sync-cache' => 'dashboard_synced',
219 'WRITE /report/dismiss-onboarding' => 'onboarding_checklist_dismissed',
220 'WRITE /activate-plugin' => 'plugin_activated',
221 'WRITE /ai/conversations' => 'ai_conversation_started',
222 'WRITE /ai/conversations/:id' => 'ai_conversation_updated',
223 'DELETE /ai/conversations/:id' => 'ai_conversation_deleted',
224 'WRITE /ai/conversations/:id/messages' => 'ai_message_sent',
225 'WRITE /ai/conversations/:id/step' => 'ai_agent_step',
226 'WRITE /ai/conversations/:id/confirm' => 'ai_tool_confirmed',
227 'WRITE /ai/generate' => 'ai_content_generated',
228 'WRITE /webhook/update-webhook/:funnel_id' => 'webhook_saved',
229 'WRITE /automation/save_crm' => 'crm_connection_saved',
230 'WRITE /automation/wpfnl-test-zapier/:funnel_id' => 'zapier_tested',
231 'WRITE /automation/save_data/:funnel_id' => 'crm_automation_saved',
232 'WRITE /automation/save_name/:funnel_id' => 'crm_automation_renamed',
233 'WRITE /mint/automation/save_data' => 'mail_mint_automation_saved',
234 'WRITE /mint/automation/save_mail_data' => 'mail_mint_automation_email_saved',
235 'WRITE /mint/automation/delete_data' => 'mail_mint_automation_deleted',
236 'WRITE /abtesting/update-settings' => 'ab_test_traffic_updated',
237 'WRITE /abtesting/save-general-settings' => 'ab_test_settings_saved',
238 'WRITE /abtesting/stop-ab-testing/:step_id' => 'ab_test_stopped',
239 'WRITE /abtesting/delete-archive-variant' => 'ab_test_archived_variant_deleted',
240 'WRITE /global-funnel/saveObRules/:step_id' => 'global_funnel_order_bump_rules_saved',
241 );
242
243 /**
244 * Server-rendered WPFunnels admin pages (`?page=`) → screen name. The React
245 * admin (`wpfunnels`, `wpfunnels-admin`) reports its own routes.
246 */
247 const SERVER_SCREENS = array(
248 'edit_funnel' => '/edit-funnel',
249 'email-builder' => '/email-builder',
250 'wpfunnels_integrations' => '/integrations',
251 'wpfunnels-setup' => '/setup-wizard',
252 'store_checkout' => '/store-checkout',
253 );
254
255 /**
256 * AI assistant settings option (see AISettings::OPTION_KEY).
257 */
258 const AI_SETTINGS_OPTION = '_wpfnl_ai_settings';
259
260 /**
261 * MCP server on/off option (default 'yes').
262 */
263 const MCP_ENABLED_OPTION = '_wpfnl_mcp_enabled';
264
265 /**
266 * Last MCP client name seen on `initialize` (tools/call requests don't carry it).
267 */
268 const MCP_CLIENT_OPTION = 'wpfnl_mcp_last_client';
269
270 /**
271 * MCP methods recorded; list/ping calls are client housekeeping and skipped.
272 */
273 const MCP_METHODS = array(
274 'initialize' => 'mcp_client_connected',
275 'tools/call' => 'mcp_tool_called',
276 'prompts/get' => 'mcp_prompt_used',
277 'resources/read' => 'mcp_resource_read',
278 );
279
280 /**
281 * Tool-name keywords → feature area, most specific first.
282 */
283 const TOOL_AREAS = array(
284 'checkout-field' => 'checkout_field',
285 'store-checkout' => 'store_checkout',
286 'order-bump' => 'order_bump',
287 'upsell' => 'offer',
288 'downsell' => 'offer',
289 'offer' => 'offer',
290 'variant' => 'ab_test',
291 'winner' => 'ab_test',
292 'ab-test' => 'ab_test',
293 'automation' => 'automation',
294 'webhook' => 'webhook',
295 'template' => 'template',
296 'optin' => 'optin',
297 'license' => 'license',
298 'context' => 'context',
299 'setup' => 'context',
300 'onboarding' => 'context',
301 'store-analysis' => 'context',
302 'step' => 'step',
303 'funnel' => 'funnel',
304 'product' => 'product',
305 'coupon' => 'product',
306 'analytics' => 'analytics',
307 'report' => 'analytics',
308 'dashboard' => 'analytics',
309 'lead' => 'analytics',
310 'setting' => 'settings',
311 'permalink' => 'settings',
312 'tracking' => 'settings',
313 'gutenberg' => 'page_builder',
314 'design' => 'page_builder',
315 'page-builder' => 'page_builder',
316 'mail-mint' => 'integration',
317 'addon' => 'integration',
318 'plugin' => 'integration',
319 'compat' => 'diagnostics',
320 'log' => 'diagnostics',
321 );
322
323 /**
324 * Singleton instance.
325 *
326 * @var ActivityTrail|null
327 */
328 private static $instance = null;
329
330 /**
331 * Linno Telemetry client (for the consent state).
332 *
333 * @var object|null
334 */
335 private $client;
336
337 /**
338 * PostHog project key.
339 *
340 * @var string
341 */
342 private $api_key;
343
344 /**
345 * Matched route pattern per in-flight REST request, keyed by object id.
346 *
347 * @var array
348 */
349 private $matched_routes = array();
350
351 /**
352 * @param object|null $client Linno Telemetry client.
353 * @param string $api_key PostHog project key.
354 */
355 public function __construct( $client = null, $api_key = '' ) {
356 $this->client = $client;
357 $this->api_key = (string) $api_key;
358 }
359
360 /**
361 * Register hooks once.
362 *
363 * @param object|null $client Linno Telemetry client.
364 * @param string $api_key PostHog project key.
365 * @return void
366 */
367 public static function init( $client = null, $api_key = '' ) {
368 if ( null === self::$instance ) {
369 self::$instance = new self( $client, $api_key );
370 self::$instance->register_hooks();
371 }
372 }
373
374 /**
375 * The running instance, or null before init().
376 *
377 * @return ActivityTrail|null
378 */
379 public static function get_instance() {
380 return self::$instance;
381 }
382
383 /**
384 * Wire recorders, the screen-view endpoint, the daily flush and the
385 * deactivation sender.
386 *
387 * @return void
388 */
389 private function register_hooks() {
390 // Every WPFunnels REST action (Free + Pro).
391 add_filter( 'rest_dispatch_request', array( $this, 'remember_route' ), 10, 4 );
392 add_filter( 'rest_request_after_callbacks', array( $this, 'record_rest_action' ), 10, 3 );
393
394 // Server-rendered admin pages; the React admin posts its own routes.
395 add_action( 'current_screen', array( $this, 'record_server_screen' ) );
396
397 // Work that finishes outside the admin request.
398 add_action(
399 'wpfunnels/wpfnl_import_complete',
400 function () {
401 $this->record( 'import_completed' );
402 }
403 );
404 add_action(
405 'wpfunnels_after_funnel_imported',
406 function ( $funnel_id = 0, $type = '', $builder = '' ) {
407 $this->record(
408 'template_funnel_imported',
409 array(
410 'template_type' => sanitize_key( (string) $type ),
411 'builder' => sanitize_key( (string) $builder ),
412 )
413 );
414 },
415 10,
416 3
417 );
418
419 // AI assistant: provider connection lifecycle and copilot tool calls.
420 add_action( 'add_option_' . self::AI_SETTINGS_OPTION, array( $this, 'on_ai_settings_added' ), 10, 2 );
421 add_action( 'update_option_' . self::AI_SETTINGS_OPTION, array( $this, 'on_ai_settings_updated' ), 10, 2 );
422 add_action(
423 'wpfnl_ai_tool_executed',
424 function ( $tool_name ) {
425 $this->record_ai_tool( (string) $tool_name, true );
426 }
427 );
428 add_action(
429 'wpfnl_ai_tool_failed',
430 function ( $tool_name, $error_code = '' ) {
431 $this->record_ai_tool( (string) $tool_name, false, (string) $error_code );
432 },
433 10,
434 2
435 );
436
437 // MCP server on/off. MCP requests are recorded by FunnelMcpObservabilityHandler.
438 add_action( 'add_option_' . self::MCP_ENABLED_OPTION, array( $this, 'on_mcp_enabled_added' ), 10, 2 );
439 add_action( 'update_option_' . self::MCP_ENABLED_OPTION, array( $this, 'on_mcp_enabled_updated' ), 10, 2 );
440
441 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
442
443 add_action( self::FLUSH_HOOK, array( $this, 'flush' ) );
444 add_action( 'init', array( $this, 'maybe_schedule_flush' ) );
445
446 add_action( 'wpfunnels_plugin_deactivated', array( $this, 'send_on_deactivation' ) );
447 }
448
449 /**
450 * Register the screen-view endpoint used by the React admin.
451 *
452 * @return void
453 */
454 public function register_routes() {
455 register_rest_route(
456 'wpfunnels/v1',
457 '/activity-trail/screen',
458 array(
459 'methods' => \WP_REST_Server::CREATABLE,
460 'callback' => array( $this, 'record_screen' ),
461 'permission_callback' => array( $this, 'can_record' ),
462 'args' => array(
463 'screen' => array(
464 'type' => 'string',
465 'required' => true,
466 ),
467 ),
468 )
469 );
470 }
471
472 /**
473 * Any logged-in user who can use WPFunnels may be recorded.
474 *
475 * @return bool
476 */
477 public function can_record() {
478 return is_user_logged_in() && ( current_user_can( 'manage_options' ) || current_user_can( 'wpf_manage_funnels' ) );
479 }
480
481 /**
482 * REST callback: record a screen (route pattern such as `/automation/:automationId`).
483 *
484 * @param \WP_REST_Request $request Request.
485 * @return \WP_REST_Response
486 */
487 public function record_screen( $request ) {
488 $this->record_screen_name( (string) $request->get_param( 'screen' ) );
489
490 return new \WP_REST_Response( null, 204 );
491 }
492
493 /**
494 * `current_screen`: record a server-rendered WPFunnels admin page.
495 *
496 * @return void
497 */
498 public function record_server_screen() {
499 try {
500 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen name.
501 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
502 if ( ! isset( self::SERVER_SCREENS[ $page ] ) || wp_doing_ajax() || ! $this->can_record() ) {
503 return;
504 }
505
506 $screen = self::SERVER_SCREENS[ $page ];
507 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen name.
508 $step = isset( $_GET['step'] ) ? sanitize_key( wp_unslash( $_GET['step'] ) ) : '';
509 if ( '/setup-wizard' === $screen && '' !== $step ) {
510 $screen .= '/' . $step;
511 }
512
513 $this->record_screen_name( $screen );
514 } catch ( \Throwable $e ) {
515 return;
516 }
517 }
518
519 /**
520 * Record a screen view from a route pattern.
521 *
522 * @param string $screen Route pattern.
523 * @return void
524 */
525 private function record_screen_name( $screen ) {
526 // Route patterns only: letters, digits, slashes, colons, dashes, underscores.
527 $screen = substr( preg_replace( '/[^a-zA-Z0-9\/:_-]/', '', $screen ), 0, 100 );
528
529 if ( '' !== $screen ) {
530 $this->record( 'screen_viewed', array( 'screen' => $screen ) );
531 }
532 }
533
534 /**
535 * Filter `rest_dispatch_request`: remember which route pattern matched.
536 *
537 * @param mixed $result Dispatch result (returned unchanged).
538 * @param \WP_REST_Request $request Request.
539 * @param string $route Matched route pattern.
540 * @param array $handler Route handler.
541 * @return mixed
542 */
543 public function remember_route( $result, $request, $route, $handler ) {
544 if ( $request instanceof \WP_REST_Request && is_string( $route ) ) {
545 $this->matched_routes[ spl_object_id( $request ) ] = $route;
546 }
547
548 return $result;
549 }
550
551 /**
552 * Filter `rest_request_after_callbacks`: record the WPFunnels action and its outcome.
553 *
554 * Writes (POST/PUT/PATCH/DELETE) are always recorded; reads (GET) only when
555 * they fail or are listed in OVERRIDES as actions.
556 *
557 * @param mixed $response Callback result (returned unchanged).
558 * @param array $handler Route handler.
559 * @param \WP_REST_Request $request Request.
560 * @return mixed
561 */
562 public function record_rest_action( $response, $handler, $request ) {
563 if ( ! $request instanceof \WP_REST_Request ) {
564 return $response;
565 }
566
567 $key = spl_object_id( $request );
568 $pattern = isset( $this->matched_routes[ $key ] ) ? $this->matched_routes[ $key ] : null;
569 unset( $this->matched_routes[ $key ] );
570
571 try {
572 // Rejected by argument validation or the permission check: WordPress
573 // skipped rest_dispatch_request, so look the pattern up instead.
574 if ( null === $pattern && is_wp_error( $response ) ) {
575 $pattern = $this->resolve_route( $request );
576 }
577 if ( null === $pattern ) {
578 return $response;
579 }
580
581 $path = $this->strip_namespace( $pattern );
582 if ( null === $path || in_array( $path, self::IGNORED_ROUTES, true ) || ! $this->can_record() ) {
583 return $response;
584 }
585
586 $method = strtoupper( $request->get_method() );
587 $class = 'GET' === $method || 'DELETE' === $method ? $method : 'WRITE';
588 $route = $this->normalize_route( $path );
589
590 list( $ok, $status, $error_code ) = $this->outcome( $response, $class );
591
592 $name = isset( self::OVERRIDES[ $class . ' ' . $route ] ) ? self::OVERRIDES[ $class . ' ' . $route ] : null;
593 if ( null === $name ) {
594 if ( 'GET' === $class && $ok ) {
595 return $response; // Plain reads are covered by screen views.
596 }
597 $name = $this->derive_name( $route, $class );
598 }
599
600 $properties = array(
601 'area' => $this->area_of( $route ),
602 'route' => $class . ' ' . $route,
603 );
604
605 if ( 'ai' === $properties['area'] || 'settings_ai' === substr( $name, 0, 11 ) ) {
606 $properties['ai_provider'] = $this->active_ai_provider();
607 }
608
609 if ( ! $ok ) {
610 $name .= '_failed';
611 $properties['http_status'] = $status;
612 if ( '' !== $error_code ) {
613 $properties['error_code'] = $error_code;
614 }
615 }
616
617 $this->record( $name, $properties );
618 } catch ( \Throwable $e ) {
619 // Telemetry must never break a request.
620 return $response;
621 }
622
623 return $response;
624 }
625
626 /**
627 * Find the registered route pattern a WPFunnels request path matches, the
628 * way WP_REST_Server does. Only used for requests rejected before dispatch.
629 *
630 * @param \WP_REST_Request $request Request.
631 * @return string|null
632 */
633 private function resolve_route( $request ) {
634 $path = (string) $request->get_route();
635 if ( null === $this->strip_namespace( $path ) || ! function_exists( 'rest_get_server' ) ) {
636 return null;
637 }
638
639 foreach ( array_keys( rest_get_server()->get_routes() ) as $route ) {
640 if ( null !== $this->strip_namespace( $route ) && preg_match( '@^' . $route . '$@i', $path ) ) {
641 return $route;
642 }
643 }
644
645 return null;
646 }
647
648 /**
649 * Record one MCP request. Called by FunnelMcpObservabilityHandler for every
650 * `mcp.request` metric the MCP adapter emits.
651 *
652 * Events:
653 * mcp_client_connected (initialize) client_name, transport
654 * mcp_tool_called (tools/call) tool, tool_area, client_name
655 * mcp_prompt_used (prompts/get) prompt, client_name
656 * mcp_resource_read (resources/read) client_name
657 * Each gets `_failed` plus error_code / error_category on failure.
658 *
659 * @param array $tags Adapter tags: method, status, params, tool_name, error_code, ...
660 * @return void
661 */
662 public function record_mcp_request( array $tags ) {
663 try {
664 $method = isset( $tags['method'] ) ? (string) $tags['method'] : '';
665 if ( ! isset( self::MCP_METHODS[ $method ] ) || ! $this->can_record() ) {
666 return;
667 }
668
669 $name = self::MCP_METHODS[ $method ];
670 $params = isset( $tags['params'] ) && is_array( $tags['params'] ) ? $tags['params'] : array();
671 $client = '';
672
673 if ( 'initialize' === $method ) {
674 $client = $this->clean_label( isset( $params['client_name'] ) ? $params['client_name'] : '' );
675 if ( '' !== $client ) {
676 update_option( self::MCP_CLIENT_OPTION, $client, false );
677 }
678 }
679
680 $properties = array(
681 'client_name' => '' !== $client ? $client : (string) get_option( self::MCP_CLIENT_OPTION, 'unknown' ),
682 'transport' => $this->clean_label( isset( $tags['transport'] ) ? $tags['transport'] : '' ),
683 );
684
685 if ( 'tools/call' === $method ) {
686 $tool = (string) ( isset( $tags['tool_name'] ) ? $tags['tool_name'] : ( isset( $params['name'] ) ? $params['name'] : '' ) );
687 $properties['tool'] = $this->short_tool_name( $tool );
688 $properties['tool_area'] = $this->tool_area( $tool );
689 if ( isset( $params['arguments_count'] ) ) {
690 $properties['arguments_count'] = (int) $params['arguments_count'];
691 }
692 } elseif ( 'prompts/get' === $method ) {
693 $properties['prompt'] = $this->short_tool_name( (string) ( isset( $params['name'] ) ? $params['name'] : '' ) );
694 }
695
696 if ( isset( $tags['status'] ) && 'error' === $tags['status'] ) {
697 $name .= '_failed';
698 if ( isset( $tags['error_code'] ) && is_scalar( $tags['error_code'] ) ) {
699 $properties['error_code'] = (string) $tags['error_code'];
700 }
701 if ( isset( $tags['error_category'] ) ) {
702 $properties['error_category'] = $this->clean_label( $tags['error_category'] );
703 }
704 }
705
706 $this->record( $name, $properties );
707 } catch ( \Throwable $e ) {
708 return;
709 }
710 }
711
712 /**
713 * Record one tool call made by the in-app AI copilot (ToolGateway).
714 *
715 * @param string $tool_name Ability name, e.g. `wpfunnels/create-funnel`.
716 * @param bool $ok Whether the tool succeeded.
717 * @param string $error_code WP_Error code on failure.
718 * @return void
719 */
720 public function record_ai_tool( $tool_name, $ok, $error_code = '' ) {
721 try {
722 if ( ! $this->can_record() ) {
723 return;
724 }
725
726 $properties = array(
727 'tool' => $this->short_tool_name( $tool_name ),
728 'tool_area' => $this->tool_area( $tool_name ),
729 'ai_provider' => $this->active_ai_provider(),
730 );
731
732 if ( ! $ok && '' !== $error_code ) {
733 $properties['error_code'] = sanitize_key( $error_code );
734 }
735
736 $this->record( $ok ? 'ai_tool_called' : 'ai_tool_called_failed', $properties );
737 } catch ( \Throwable $e ) {
738 return;
739 }
740 }
741
742 /**
743 * `add_option__wpfnl_ai_settings`: first AI settings save.
744 *
745 * @param string $option Option name.
746 * @param mixed $value New value.
747 * @return void
748 */
749 public function on_ai_settings_added( $option, $value ) {
750 $this->on_ai_settings_updated( array(), $value );
751 }
752
753 /**
754 * `update_option__wpfnl_ai_settings`: turn the settings diff into events.
755 *
756 * Events: ai_provider_connected / ai_provider_disconnected (provider, model),
757 * ai_provider_switched (provider), ai_model_changed (provider, model),
758 * ai_assistant_enabled / ai_assistant_disabled, and
759 * ai_mail_mint_connection_enabled / _disabled. API keys are never read.
760 *
761 * @param mixed $old_value Previous settings.
762 * @param mixed $new_value New settings.
763 * @return void
764 */
765 public function on_ai_settings_updated( $old_value, $new_value ) {
766 try {
767 $old = is_array( $old_value ) ? $old_value : array();
768 $new = is_array( $new_value ) ? $new_value : array();
769
770 $old_providers = $this->connected_ai_providers( $old );
771 $new_providers = $this->connected_ai_providers( $new );
772
773 foreach ( array_diff_key( $new_providers, $old_providers ) as $provider => $model ) {
774 $this->record( 'ai_provider_connected', array( 'provider' => $provider, 'model' => $model ) );
775 }
776 foreach ( array_diff_key( $old_providers, $new_providers ) as $provider => $model ) {
777 $this->record( 'ai_provider_disconnected', array( 'provider' => $provider, 'model' => $model ) );
778 }
779 foreach ( array_intersect_key( $new_providers, $old_providers ) as $provider => $model ) {
780 if ( $model !== $old_providers[ $provider ] ) {
781 $this->record( 'ai_model_changed', array( 'provider' => $provider, 'model' => $model ) );
782 }
783 }
784
785 $old_active = isset( $old['active_provider'] ) ? sanitize_key( (string) $old['active_provider'] ) : '';
786 $new_active = isset( $new['active_provider'] ) ? sanitize_key( (string) $new['active_provider'] ) : '';
787 if ( '' !== $new_active && $new_active !== $old_active && isset( $old_providers[ $new_active ] ) ) {
788 $this->record( 'ai_provider_switched', array( 'provider' => $new_active ) );
789 }
790
791 $old_enabled = ! empty( $old['enabled'] );
792 $new_enabled = ! empty( $new['enabled'] );
793 if ( $old_enabled !== $new_enabled ) {
794 $this->record( $new_enabled ? 'ai_assistant_enabled' : 'ai_assistant_disabled', array( 'provider' => $new_active ) );
795 }
796
797 $old_mint = ! empty( $old['use_mail_mint_connection'] );
798 $new_mint = ! empty( $new['use_mail_mint_connection'] );
799 if ( $old_mint !== $new_mint ) {
800 $this->record( $new_mint ? 'ai_mail_mint_connection_enabled' : 'ai_mail_mint_connection_disabled' );
801 }
802 } catch ( \Throwable $e ) {
803 return;
804 }
805 }
806
807 /**
808 * `add_option__wpfnl_mcp_enabled`.
809 *
810 * @param string $option Option name.
811 * @param mixed $value New value.
812 * @return void
813 */
814 public function on_mcp_enabled_added( $option, $value ) {
815 $this->on_mcp_enabled_updated( 'yes', $value );
816 }
817
818 /**
819 * `update_option__wpfnl_mcp_enabled`: MCP server switched on or off.
820 *
821 * @param mixed $old_value Previous value ('yes' / 'no').
822 * @param mixed $new_value New value.
823 * @return void
824 */
825 public function on_mcp_enabled_updated( $old_value, $new_value ) {
826 $was = 'no' !== $old_value;
827 $now = 'no' !== $new_value;
828
829 if ( $was !== $now ) {
830 $this->record( $now ? 'mcp_server_enabled' : 'mcp_server_disabled' );
831 }
832 }
833
834 /**
835 * Connected AI providers and their models, from an AI settings array.
836 *
837 * @param array $settings AI settings.
838 * @return array provider => model
839 */
840 private function connected_ai_providers( array $settings ) {
841 $connected = array();
842 $providers = isset( $settings['providers'] ) && is_array( $settings['providers'] ) ? $settings['providers'] : array();
843
844 foreach ( $providers as $provider => $config ) {
845 if ( is_array( $config ) && ( ! empty( $config['key'] ) || ! empty( $config['connected'] ) ) ) {
846 $connected[ sanitize_key( (string) $provider ) ] = $this->clean_label( isset( $config['model'] ) ? $config['model'] : '' );
847 }
848 }
849
850 return $connected;
851 }
852
853 /**
854 * Active AI provider slug, or '' when none.
855 *
856 * @return string
857 */
858 private function active_ai_provider() {
859 $settings = get_option( self::AI_SETTINGS_OPTION, array() );
860
861 return is_array( $settings ) && isset( $settings['active_provider'] ) ? sanitize_key( (string) $settings['active_provider'] ) : '';
862 }
863
864 /**
865 * Same short name for copilot and MCP tools:
866 * `wpfunnels/create-funnel` (ability) and `wpfunnels-create-funnel`
867 * (MCP flattens `/` to `-`) both become `create_funnel`.
868 *
869 * @param string $tool_name Ability, MCP tool or prompt name.
870 * @return string
871 */
872 private function short_tool_name( $tool_name ) {
873 $short = strtolower( substr( (string) strrchr( '/' . $tool_name, '/' ), 1 ) );
874 $short = preg_replace( '/^(wpfunnels-pro-|wpfunnels-)/', '', $short );
875
876 return sanitize_key( str_replace( '-', '_', $short ) );
877 }
878
879 /**
880 * Feature area of a tool, from keywords in its name.
881 *
882 * @param string $tool_name Ability name.
883 * @return string
884 */
885 private function tool_area( $tool_name ) {
886 $short = str_replace( '_', '-', $this->short_tool_name( $tool_name ) );
887
888 foreach ( self::TOOL_AREAS as $keyword => $area ) {
889 if ( false !== strpos( $short, $keyword ) ) {
890 return $area;
891 }
892 }
893
894 return 'other';
895 }
896
897 /**
898 * Short, safe label (client names, model ids, categories).
899 *
900 * @param mixed $value Raw value.
901 * @return string
902 */
903 private function clean_label( $value ) {
904 return is_scalar( $value ) ? substr( preg_replace( '/[^a-zA-Z0-9 ._:\/-]/', '', (string) $value ), 0, 60 ) : '';
905 }
906
907 /**
908 * Return the route without its namespace, or null if it isn't a WPFunnels admin route.
909 *
910 * @param string $pattern Full route pattern, e.g. `/wpfunnels/v1/funnels`.
911 * @return string|null
912 */
913 private function strip_namespace( $pattern ) {
914 foreach ( self::NAMESPACES as $namespace ) {
915 $prefix = '/' . $namespace;
916 if ( 0 !== strpos( $pattern, $prefix . '/' ) ) {
917 continue;
918 }
919
920 $path = substr( $pattern, strlen( $prefix ) );
921 if ( isset( self::NAMESPACE_PATHS[ $namespace ] ) ) {
922 foreach ( self::NAMESPACE_PATHS[ $namespace ] as $from => $to ) {
923 if ( $path === $from || 0 === strpos( $path, $from . '/' ) ) {
924 $path = $to . substr( $path, strlen( $from ) );
925 break;
926 }
927 }
928 }
929
930 return $path;
931 }
932
933 return null;
934 }
935
936 /**
937 * Turn a route regex into a readable pattern:
938 * `/funnels/(?P<funnel_id>\d+)/duplicate` → `/funnels/:funnel_id/duplicate`,
939 * and optional groups `(?:...)?` are dropped.
940 *
941 * @param string $path Route regex without namespace.
942 * @return string
943 */
944 public function normalize_route( $path ) {
945 do {
946 $previous = $path;
947 $path = preg_replace( '/\(\?:[^()]*\)\?/', '', $path );
948 $path = preg_replace( '/\(\?:(?:[^()]|\((?:[^()])*\))*\)\?/', '', $path );
949 } while ( $path !== $previous );
950
951 $route = preg_replace( '/\(\?P<([a-zA-Z0-9_]+)>(?:[^()]|\([^()]*\))*\)/', ':$1', $path );
952
953 return rtrim( $route, '/' );
954 }
955
956 /**
957 * Name an action from its route.
958 *
959 * Examples:
960 * WRITE /funnels → funnel_created
961 * DELETE /funnels/:funnel_id → funnel_deleted
962 * WRITE /funnels/:funnel_id/duplicate → funnel_duplicated
963 * DELETE /steps/:step_id/delete-step → step_deleted
964 * WRITE /abtesting/create-varient → ab_test_variant_created
965 * WRITE /automation-canvas/:id → automation_updated
966 * WRITE /settings/ai → settings_ai_saved
967 *
968 * A verb word in the route (create, save, delete, duplicate, ...) moves to
969 * the end in the past tense; words repeating the area are dropped.
970 *
971 * @param string $route Normalized route.
972 * @param string $class GET, WRITE or DELETE.
973 * @return string
974 */
975 public function derive_name( $route, $class ) {
976 $segments = array_values( array_filter( explode( '/', $route ), 'strlen' ) );
977 $literals = array_values(
978 array_filter(
979 $segments,
980 function ( $segment ) {
981 return ':' !== $segment[0];
982 }
983 )
984 );
985
986 if ( empty( $literals ) ) {
987 return 'unknown';
988 }
989
990 $area = $this->area_of( $route );
991 $area_words = explode( '_', $area );
992 $ends_in_param = ':' === substr( end( $segments ), 0, 1 );
993 $words = array();
994
995 foreach ( array_slice( $literals, 1 ) as $literal ) {
996 foreach ( explode( '_', $this->to_snake( $literal ) ) as $word ) {
997 $word = 'varient' === $word ? 'variant' : $word; // Route typo.
998 if ( '' === $word || in_array( $word, self::NOISE_WORDS, true )
999 || in_array( $word, $area_words, true ) || in_array( rtrim( $word, 's' ), $area_words, true ) ) {
1000 continue;
1001 }
1002 $words[] = $word;
1003 }
1004 }
1005
1006 $verb = null;
1007 foreach ( $words as $index => $word ) {
1008 if ( isset( self::VERBS[ $word ] ) ) {
1009 $verb = self::VERBS[ $word ];
1010 unset( $words[ $index ] );
1011 break;
1012 }
1013 }
1014
1015 if ( 'DELETE' === $class && ( null === $verb || '' === $verb ) ) {
1016 $verb = 'deleted';
1017 } elseif ( null === $verb && 'WRITE' === $class && empty( $words ) ) {
1018 $verb = $ends_in_param ? 'updated' : 'created';
1019 } elseif ( null === $verb && 'WRITE' === $class && 'settings' === $area && 1 === count( $words ) ) {
1020 $verb = 'saved';
1021 } elseif ( null === $verb ) {
1022 $verb = '';
1023 }
1024
1025 $name = implode( '_', array_filter( array_merge( array( $area ), $words, array( $verb ) ), 'strlen' ) );
1026
1027 return trim( preg_replace( '/_+/', '_', preg_replace( '/[^a-z0-9_]/', '_', strtolower( $name ) ) ), '_' );
1028 }
1029
1030 /**
1031 * Feature area of a normalized route (its first literal segment).
1032 *
1033 * @param string $route Normalized route.
1034 * @return string
1035 */
1036 private function area_of( $route ) {
1037 $segments = array_values( array_filter( explode( '/', $route ), 'strlen' ) );
1038 $first = isset( $segments[0] ) ? $segments[0] : '';
1039
1040 return isset( self::AREA_ALIASES[ $first ] ) ? self::AREA_ALIASES[ $first ] : $this->to_snake( $first );
1041 }
1042
1043 /**
1044 * `saveFunnel` / `status-update` → `save_funnel` / `status_update`.
1045 *
1046 * @param string $segment Route segment.
1047 * @return string
1048 */
1049 private function to_snake( $segment ) {
1050 return strtolower( str_replace( '-', '_', preg_replace( '/(?<!^)[A-Z]/', '_$0', $segment ) ) );
1051 }
1052
1053 /**
1054 * Work out whether a REST callback succeeded.
1055 *
1056 * WPFunnels signals failure with an HTTP status >= 400, a WP_Error, or a
1057 * 200 body with `success: false` or `status: 'error'`. The callback result is
1058 * often a plain array (rest_ensure_response() runs after this filter). For
1059 * reads, a `success: false` body usually means "nothing found", so only
1060 * HTTP errors count there.
1061 *
1062 * @param mixed $response Callback result.
1063 * @param string $class GET, WRITE or DELETE.
1064 * @return array [ bool $ok, int $status, string $error_code ]
1065 */
1066 public function outcome( $response, $class = 'WRITE' ) {
1067 if ( is_wp_error( $response ) ) {
1068 $data = $response->get_error_data();
1069 $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500;
1070
1071 return array( false, $status, sanitize_key( (string) $response->get_error_code() ) );
1072 }
1073
1074 $status = 200;
1075 $data = $response;
1076 if ( $response instanceof \WP_HTTP_Response ) {
1077 $status = (int) $response->get_status();
1078 $data = $response->get_data();
1079 }
1080
1081 $failed = false;
1082 if ( 'GET' !== $class && is_array( $data ) ) {
1083 $failed = ( array_key_exists( 'success', $data ) && false === $data['success'] )
1084 || ( isset( $data['status'] ) && in_array( $data['status'], array( 'error', 'fail', 'failed' ), true ) );
1085 }
1086
1087 $code = is_array( $data ) && isset( $data['code'] ) && is_string( $data['code'] ) ? sanitize_key( $data['code'] ) : '';
1088 $ok = $status < 400 && ! $failed;
1089
1090 return array( $ok, $status, $ok ? '' : $code );
1091 }
1092
1093 /**
1094 * Append an entry to the trail.
1095 *
1096 * Repeating the last unsent activity with the same properties within
1097 * COLLAPSE_WINDOW updates that entry and bumps its count instead.
1098 *
1099 * @param string $activity Activity name.
1100 * @param array $properties Extra scalar properties.
1101 * @return void
1102 */
1103 public function record( $activity, array $properties = array() ) {
1104 try {
1105 $trail = $this->get_trail();
1106 $now = time();
1107 $index = count( $trail ) - 1;
1108
1109 if ( $index >= 0 ) {
1110 $last = $trail[ $index ];
1111 if ( empty( $last['s'] ) && $last['a'] === $activity
1112 && ( isset( $last['p'] ) ? $last['p'] : array() ) === $properties
1113 && $now - (int) $last['t'] <= self::COLLAPSE_WINDOW ) {
1114 $trail[ $index ]['t'] = $now;
1115 $trail[ $index ]['n'] = (int) ( isset( $last['n'] ) ? $last['n'] : 1 ) + 1;
1116 $this->save_trail( $trail );
1117 return;
1118 }
1119 }
1120
1121 $trail[] = array(
1122 'a' => $activity,
1123 'p' => $properties,
1124 't' => $now,
1125 );
1126
1127 $this->save_trail( $trail );
1128 } catch ( \Throwable $e ) {
1129 return;
1130 }
1131 }
1132
1133 /**
1134 * Load the trail, dropping expired or malformed entries.
1135 *
1136 * @return array
1137 */
1138 public function get_trail() {
1139 $trail = get_option( self::OPTION, array() );
1140
1141 if ( ! is_array( $trail ) ) {
1142 return array();
1143 }
1144
1145 $cutoff = time() - self::MAX_AGE;
1146
1147 return array_values(
1148 array_filter(
1149 $trail,
1150 function ( $entry ) use ( $cutoff ) {
1151 return is_array( $entry ) && isset( $entry['a'], $entry['t'] ) && $entry['t'] >= $cutoff;
1152 }
1153 )
1154 );
1155 }
1156
1157 /**
1158 * Persist the trail, keeping the newest MAX_UNSENT unsent and KEEP_SENT sent entries.
1159 *
1160 * @param array $trail Trail entries.
1161 * @return void
1162 */
1163 private function save_trail( array $trail ) {
1164 $kept = array();
1165 $sent = 0;
1166 $unsent = 0;
1167
1168 for ( $i = count( $trail ) - 1; $i >= 0; $i-- ) {
1169 if ( ! empty( $trail[ $i ]['s'] ) ) {
1170 if ( $sent++ < self::KEEP_SENT ) {
1171 $kept[] = $trail[ $i ];
1172 }
1173 } elseif ( $unsent++ < self::MAX_UNSENT ) {
1174 $kept[] = $trail[ $i ];
1175 }
1176 }
1177
1178 $kept = array_reverse( $kept );
1179
1180 if ( false === get_option( self::OPTION, false ) ) {
1181 add_option( self::OPTION, $kept, '', false );
1182 return;
1183 }
1184
1185 update_option( self::OPTION, $kept, false );
1186 }
1187
1188 /**
1189 * Whether the site owner opted in to usage tracking.
1190 *
1191 * @return bool
1192 */
1193 private function is_opted_in() {
1194 return $this->client && method_exists( $this->client, 'get_optin_state' )
1195 && 'yes' === $this->client->get_optin_state();
1196 }
1197
1198 /**
1199 * Keep the daily flush scheduled only while the site is opted in.
1200 *
1201 * @return void
1202 */
1203 public function maybe_schedule_flush() {
1204 try {
1205 $next = wp_next_scheduled( self::FLUSH_HOOK );
1206
1207 if ( $this->is_opted_in() ) {
1208 if ( ! $next ) {
1209 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::FLUSH_HOOK );
1210 }
1211 } elseif ( $next ) {
1212 wp_unschedule_event( $next, self::FLUSH_HOOK );
1213 }
1214 } catch ( \Throwable $e ) {
1215 return;
1216 }
1217 }
1218
1219 /**
1220 * Daily cron (opted-in sites only): send unsent entries and mark them sent.
1221 *
1222 * @return void
1223 */
1224 public function flush() {
1225 if ( ! $this->is_opted_in() ) {
1226 return;
1227 }
1228
1229 $unsent = $this->unsent( $this->get_trail() );
1230 if ( empty( $unsent ) ) {
1231 return;
1232 }
1233
1234 $last_sent_at = max( array_column( $unsent, 't' ) );
1235
1236 if ( ! $this->send( $unsent ) ) {
1237 return;
1238 }
1239
1240 // Re-read so entries recorded while sending are kept unsent.
1241 $trail = $this->get_trail();
1242 foreach ( $trail as $index => $entry ) {
1243 if ( empty( $entry['s'] ) && (int) $entry['t'] <= $last_sent_at ) {
1244 $trail[ $index ]['s'] = true;
1245 }
1246 }
1247 $this->save_trail( $trail );
1248 }
1249
1250 /**
1251 * On deactivation (every site): send unsent entries plus a summary event.
1252 *
1253 * @return void
1254 */
1255 public function send_on_deactivation() {
1256 try {
1257 wp_clear_scheduled_hook( self::FLUSH_HOOK );
1258
1259 // Always send the context, even with an empty trail: deactivation_source
1260 // explains the SDK's `reason: none` deactivations on sites with no activity.
1261 $trail = $this->get_trail();
1262
1263 if ( $this->send( $this->unsent( $trail ), $this->build_context( $trail ) ) ) {
1264 delete_option( self::OPTION );
1265 }
1266 } catch ( \Throwable $e ) {
1267 // Never block deactivation on telemetry.
1268 return;
1269 }
1270 }
1271
1272 /**
1273 * Entries not yet sent.
1274 *
1275 * @param array $trail Trail entries.
1276 * @return array
1277 */
1278 private function unsent( array $trail ) {
1279 return array_values(
1280 array_filter(
1281 $trail,
1282 function ( $entry ) {
1283 return empty( $entry['s'] );
1284 }
1285 )
1286 );
1287 }
1288
1289 /**
1290 * Summary of the whole trail for the `deactivation_context` event.
1291 *
1292 * @param array $trail All entries (sent and unsent).
1293 * @return array
1294 */
1295 public function build_context( array $trail ) {
1296 $now = time();
1297 $last = empty( $trail ) ? null : end( $trail );
1298 $last_feature = '';
1299 $last_screen = '';
1300 $last_failure = '';
1301 $failures = 0;
1302
1303 foreach ( $trail as $entry ) {
1304 if ( 'screen_viewed' === $entry['a'] ) {
1305 $last_screen = isset( $entry['p']['screen'] ) ? $entry['p']['screen'] : '';
1306 continue;
1307 }
1308 $last_feature = $entry['a'];
1309 if ( '_failed' === substr( $entry['a'], -7 ) ) {
1310 $last_failure = $entry['a'];
1311 ++$failures;
1312 }
1313 }
1314
1315 $installed = (int) get_option( 'wpfunnels_installed_time', 0 );
1316
1317 return array(
1318 'deactivation_source' => $this->deactivation_source(),
1319 'modal_reason_submitted' => $this->modal_submitted(),
1320 'last_activity' => $last ? $this->label( $last ) : '',
1321 'last_feature' => $last_feature,
1322 'last_screen' => $last_screen,
1323 'last_failure' => $last_failure,
1324 'failures_in_trail' => $failures,
1325 'minutes_since_last_activity' => $last ? (int) floor( ( $now - (int) $last['t'] ) / MINUTE_IN_SECONDS ) : null,
1326 'days_since_install' => $installed ? (int) floor( ( $now - $installed ) / DAY_IN_SECONDS ) : null,
1327 'trail_length' => count( $trail ),
1328 'recent_activities' => array_map( array( $this, 'label' ), array_slice( $trail, -15 ) ),
1329 'opted_in' => $this->is_opted_in(),
1330 );
1331 }
1332
1333 /**
1334 * Whether the deactivation feedback modal sent a reason in the last minute.
1335 *
1336 * WPFunnels renders its own modal but submits through the SDK's AJAX
1337 * handler, which sets `<slug>_deactivation_event_sent`. The SDK deletes it
1338 * in Client::deactivate(), so deactivate_wpfnl() is registered before the
1339 * SDK client is built (see wpfnl.php) to read it first.
1340 *
1341 * @return bool
1342 */
1343 private function modal_submitted() {
1344 return 'yes' === get_transient( self::SDK_SLUG . '_deactivation_event_sent' );
1345 }
1346
1347 /**
1348 * How the plugin is being deactivated.
1349 *
1350 * The deactivation modal only opens for the single "Deactivate" link on
1351 * plugins.php; every other route makes the SDK send `reason: none`. This
1352 * tells those routes apart.
1353 *
1354 * @return string wp_cli | rest_api | xmlrpc | cron | recovery_mode | network_admin |
1355 * bulk_action | modal_submitted | link_modal_skipped | ajax | programmatic
1356 */
1357 public function deactivation_source() {
1358 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only classification; core verified the request.
1359 $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
1360 $action2 = isset( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : '';
1361 $network = ! empty( $_REQUEST['networkwide'] );
1362 // phpcs:enable
1363
1364 if ( defined( 'WP_CLI' ) && WP_CLI ) {
1365 return 'wp_cli';
1366 }
1367 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
1368 return 'rest_api';
1369 }
1370 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
1371 return 'xmlrpc';
1372 }
1373 if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
1374 return 'cron';
1375 }
1376 if ( function_exists( 'wp_is_recovery_mode' ) && wp_is_recovery_mode() ) {
1377 return 'recovery_mode';
1378 }
1379 if ( $network || ( function_exists( 'is_network_admin' ) && is_network_admin() ) ) {
1380 return 'network_admin';
1381 }
1382 if ( 'deactivate-selected' === $action || 'deactivate-selected' === $action2 ) {
1383 return 'bulk_action';
1384 }
1385 if ( 'deactivate' === $action ) {
1386 return $this->modal_submitted() ? 'modal_submitted' : 'link_modal_skipped';
1387 }
1388 if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
1389 return 'ajax';
1390 }
1391
1392 return 'programmatic';
1393 }
1394
1395 /**
1396 * Short label for an entry, e.g. `screen:/funnels` or `funnel_created`.
1397 *
1398 * @param array $entry Trail entry.
1399 * @return string
1400 */
1401 private function label( $entry ) {
1402 return 'screen_viewed' === $entry['a'] ? 'screen:' . ( isset( $entry['p']['screen'] ) ? $entry['p']['screen'] : '' ) : $entry['a'];
1403 }
1404
1405 /**
1406 * Send entries (and optionally a deactivation summary) to PostHog.
1407 *
1408 * Screens go out as PostHog `$screen` events (`$screen_name` = `wpfunnels<route>`)
1409 * so they appear as screens in Paths; everything else as `wpfnl_activity/<name>`.
1410 *
1411 * @param array $entries Entries to send, oldest first.
1412 * @param array|null $context Deactivation summary, or null.
1413 * @return bool True when PostHog accepted the batch.
1414 */
1415 protected function send( array $entries, $context = null ) {
1416 if ( ( empty( $entries ) && null === $context ) || '' === $this->api_key
1417 || ! class_exists( PostHog::class ) || ! class_exists( Utils::class ) ) {
1418 return false;
1419 }
1420
1421 $now = time();
1422 $distinct_id = Utils::getUniqueSiteId();
1423 $base = array(
1424 'plugin_name' => 'WPFunnels',
1425 'plugin_version' => defined( 'WPFNL_VERSION' ) ? WPFNL_VERSION : '',
1426 'is_pro_active' => defined( 'WPFNL_PRO_VERSION' ),
1427 'source' => null === $context ? 'activity_trail_daily' : 'activity_trail_deactivation',
1428 );
1429
1430 try {
1431 PostHog::init( $this->api_key, array( 'host' => self::POSTHOG_HOST ) );
1432
1433 foreach ( $entries as $entry ) {
1434 $properties = array_merge(
1435 $base,
1436 isset( $entry['p'] ) && is_array( $entry['p'] ) ? $entry['p'] : array(),
1437 array( 'repeat_count' => (int) ( isset( $entry['n'] ) ? $entry['n'] : 1 ) )
1438 );
1439
1440 if ( null !== $context ) {
1441 $properties['seconds_before_deactivation'] = max( 0, $now - (int) $entry['t'] );
1442 }
1443
1444 if ( 'screen_viewed' === $entry['a'] ) {
1445 $event = '$screen';
1446 $properties['$screen_name'] = 'wpfunnels' . ( isset( $entry['p']['screen'] ) ? $entry['p']['screen'] : '' );
1447 } else {
1448 $event = self::EVENT_PREFIX . $entry['a'];
1449 }
1450
1451 PostHog::capture(
1452 array(
1453 'distinctId' => $distinct_id,
1454 'event' => $event,
1455 'properties' => $properties,
1456 'timestamp' => (int) $entry['t'],
1457 )
1458 );
1459 }
1460
1461 if ( null !== $context ) {
1462 PostHog::capture(
1463 array(
1464 'distinctId' => $distinct_id,
1465 'event' => self::EVENT_PREFIX . 'deactivation_context',
1466 'properties' => array_merge( $base, $context ),
1467 'timestamp' => $now,
1468 )
1469 );
1470 }
1471
1472 // flush() returns the last HttpResponse (always truthy), so check the status code.
1473 $response = PostHog::flush();
1474
1475 return is_object( $response ) && method_exists( $response, 'getResponseCode' )
1476 ? 200 === (int) $response->getResponseCode()
1477 : (bool) $response;
1478 } catch ( \Throwable $e ) {
1479 // Never block the request or deactivation on telemetry.
1480 return false;
1481 }
1482 }
1483 }
1484