-
Notifications
You must be signed in to change notification settings - Fork 36
Wire activation-funnel analytics events #1189
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,152 @@ public function __construct( Plugin $plugin ) { | |
| add_filter( 'cloudinary_api_rest_endpoints', array( $this, 'rest_endpoints' ) ); | ||
| add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_script_data' ) ); | ||
| add_action( 'admin_init', array( $this, 'maybe_send_smoke_event' ) ); | ||
| add_action( 'admin_init', array( $this, 'maybe_send_pending_activation' ) ); | ||
| add_action( 'cloudinary_uploaded_asset', array( $this, 'maybe_first_api_consumption' ), 10, 2 ); | ||
| } | ||
|
|
||
| /** | ||
| * Option/transient keys used by the activation funnel. | ||
| */ | ||
| const PENDING_ACTIVATION = '_cloudinary_pending_activation'; | ||
| const LAST_ACTIVE = '_cloudinary_last_active'; | ||
| const FIRST_API_FLAG = '_cloudinary_first_api_emitted'; | ||
|
|
||
| /** | ||
| * Records the activation type on plugin activation (funnel step 1). | ||
| * | ||
| * Runs from the activation hook (`Utils::install`). Detects fresh install / | ||
| * reactivation / upgrade / downgrade from the persisted install marker | ||
| * (`db_version`) vs. the current version, then stashes a transient that the | ||
| * next admin load turns into a `plugin_activated` event — by which point the | ||
| * full mandatory params and `session_id` are available. | ||
| * | ||
| * @return void | ||
| */ | ||
| public static function stash_activation() { | ||
| try { | ||
| $current = get_plugin_instance()->version; | ||
| $db_version = get_option( Sync::META_KEYS['db_version'] ); | ||
|
|
||
| if ( empty( $db_version ) ) { | ||
| $type = 'fresh_install'; | ||
| $previous = null; | ||
| } elseif ( version_compare( $db_version, $current, '<' ) ) { | ||
| $type = 'upgrade'; | ||
| $previous = $db_version; | ||
| } elseif ( version_compare( $db_version, $current, '>' ) ) { | ||
| $type = 'downgrade'; | ||
| $previous = $db_version; | ||
| } else { | ||
| $type = 'reactivation'; | ||
| $previous = $db_version; | ||
| } | ||
|
|
||
| $days_since_last_active = null; | ||
| if ( 'reactivation' === $type ) { | ||
| $last = (int) get_option( self::LAST_ACTIVE ); | ||
| if ( $last > 0 ) { | ||
| $days_since_last_active = (int) floor( ( time() - $last ) / DAY_IN_SECONDS ); | ||
| } | ||
| } | ||
|
|
||
| set_transient( | ||
| self::PENDING_ACTIVATION, | ||
| array( | ||
| 'activation_type' => $type, | ||
| 'previous_version' => $previous, | ||
| 'new_version' => $current, | ||
| 'days_since_last_active' => $days_since_last_active, | ||
| ), | ||
| HOUR_IN_SECONDS | ||
| ); | ||
| } catch ( \Throwable $e ) { | ||
| // Fail silent: activation must never break. | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Persists a last-active timestamp on deactivation. | ||
| * | ||
| * Feeds `days_since_last_active` on the next reactivation. Runs from the | ||
| * deactivation hook. | ||
| * | ||
| * @return void | ||
| */ | ||
| public static function record_deactivation() { | ||
| update_option( self::LAST_ACTIVE, time(), false ); | ||
| } | ||
|
|
||
| /** | ||
| * Emits the stashed `plugin_activated` event on the next admin load. | ||
| * | ||
| * @return void | ||
| */ | ||
| public function maybe_send_pending_activation() { | ||
| $pending = get_transient( self::PENDING_ACTIVATION ); | ||
| if ( empty( $pending ) || ! is_array( $pending ) ) { | ||
| return; | ||
| } | ||
| delete_transient( self::PENDING_ACTIVATION ); | ||
|
|
||
| $params = array( | ||
| 'activation_type' => $pending['activation_type'], | ||
| 'new_version' => $pending['new_version'], | ||
| ); | ||
| if ( ! empty( $pending['previous_version'] ) ) { | ||
| $params['previous_version'] = $pending['previous_version']; | ||
| } | ||
| if ( isset( $pending['days_since_last_active'] ) && null !== $pending['days_since_last_active'] ) { | ||
| $params['days_since_last_active'] = $pending['days_since_last_active']; | ||
| } | ||
|
|
||
| $this->track( 'plugin_activated', 'activation_funnel', 1, $params ); | ||
| } | ||
|
|
||
| /** | ||
| * Emits the one-time `first_api_consumption` activation marker (funnel step 9). | ||
| * | ||
| * Hooked to `cloudinary_uploaded_asset`, which fires after an asset upload. | ||
| * Emitted once on the first successful upload, then suppressed. | ||
| * | ||
| * @param int $attachment_id The attachment ID. | ||
| * @param array|\WP_Error $result The upload result. | ||
| * | ||
| * @return void | ||
| */ | ||
| public function maybe_first_api_consumption( $attachment_id, $result ) { | ||
| if ( empty( $result ) || is_wp_error( $result ) ) { | ||
| return; | ||
| } | ||
|
|
||
| // One-time per account: suppress only if already sent for this cloud_name | ||
| // (so a switched/added account re-emits). | ||
| $connect = $this->plugin->get_component( 'connect' ); | ||
| $cloud = $connect ? (string) $connect->get_cloud_name() : ''; | ||
| if ( get_option( self::FIRST_API_FLAG ) === $cloud ) { | ||
| return; | ||
| } | ||
| update_option( self::FIRST_API_FLAG, $cloud, false ); | ||
|
|
||
| $asset_type = ''; | ||
| if ( is_array( $result ) && ! empty( $result['resource_type'] ) ) { | ||
| $asset_type = $result['resource_type']; | ||
| } | ||
|
|
||
| $this->track( | ||
| 'first_api_consumption', | ||
| 'activation_funnel', | ||
| 9, | ||
| array( | ||
| // This hook fires only for uploads (cloudinary_uploaded_asset); a | ||
| // non-error result implies HTTP 2xx — the action does not surface | ||
| // the raw status code. | ||
| 'api_endpoint' => 'upload', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Re Same §2.3 issue from the bridge: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing Also |
||
| 'http_status' => 200, | ||
| 'asset_type' => $asset_type, | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -144,7 +290,11 @@ protected function dispatch( array $event ) { | |
| array( | ||
| 'timeout' => 1, | ||
| 'blocking' => false, | ||
| 'body' => $event, | ||
| // JSON body so booleans (is_multisite, format_valid, …) stay real | ||
| // booleans on the wire, per spec §2.3 (a form body would coerce | ||
| // them to "1"/""). | ||
| 'headers' => array( 'Content-Type' => 'application/json' ), | ||
| 'body' => wp_json_encode( $event ), | ||
| ) | ||
| ); | ||
| } | ||
|
|
@@ -258,8 +408,12 @@ public function rest_track( WP_REST_Request $request ) { | |
| $clean = array(); | ||
| if ( is_array( $params ) ) { | ||
| foreach ( $params as $key => $value ) { | ||
| if ( is_scalar( $value ) ) { | ||
| $clean[ sanitize_key( $key ) ] = sanitize_text_field( (string) $value ); | ||
| $key = sanitize_key( $key ); | ||
| // Preserve booleans and numbers (spec §2.3); sanitize strings only. | ||
| if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) { | ||
| $clean[ $key ] = $value; | ||
| } elseif ( is_string( $value ) ) { | ||
| $clean[ $key ] = sanitize_text_field( $value ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -153,6 +153,22 @@ public function rest_test_connection( WP_REST_Request $request ) { | |
| $url = $request->get_param( 'cloudinary_url' ); | ||
| $result = $this->test_connection( $url ); | ||
|
|
||
| $analytics = $this->plugin->get_component( 'analytics' ); | ||
| if ( $analytics ) { | ||
| $success = isset( $result['type'] ) && 'connection_success' === $result['type']; | ||
| $analytics->track( | ||
| 'connection_test_result', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Also L165 reads |
||
| 'activation_funnel', | ||
| 3, | ||
| array( | ||
| 'status' => $success ? 'success' : 'error', | ||
| 'error_type' => $success ? '' : ( isset( $result['type'] ) ? $result['type'] : 'unknown' ), | ||
| 'http_status' => isset( $result['http_status'] ) ? (int) $result['http_status'] : 0, | ||
| 'attempt_number' => (int) $request->get_param( 'attempt_number' ), | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| return rest_ensure_response( $result ); | ||
| } | ||
|
|
||
|
|
@@ -224,6 +240,22 @@ public function rest_save_wizard( WP_REST_Request $request ) { | |
| ); | ||
| } | ||
|
|
||
| $analytics = $this->plugin->get_component( 'analytics' ); | ||
| if ( $analytics ) { | ||
| $analytics->track( | ||
| 'wizard_setup_submitted', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 'activation_funnel', | ||
| 5, | ||
| array( | ||
| 'media_library' => 'on' === $media, | ||
| 'non_media' => 'on' === $nonmedia, | ||
| 'advanced' => 'on' === $advanced, | ||
| 'status' => 'success', | ||
| 'http_status' => 200, | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| return rest_ensure_response( $this->settings->get_value() ); | ||
| } | ||
|
|
||
|
|
@@ -386,9 +418,10 @@ public function is_connected() { | |
| */ | ||
| public function test_connection( $url ) { | ||
| $result = array( | ||
| 'type' => 'connection_success', | ||
| 'message' => null, | ||
| 'url' => $url, | ||
| 'type' => 'connection_success', | ||
| 'message' => null, | ||
| 'url' => $url, | ||
| 'http_status' => 0, | ||
| ); | ||
|
|
||
| $test = wp_parse_url( $url ); | ||
|
|
@@ -430,7 +463,10 @@ function ( $a ) { | |
| $result['type'] = 'connection_error'; | ||
| } | ||
| $result['message'] = ucwords( str_replace( '_', ' ', $test_result->get_error_message() ) ); | ||
| // `API::call()` returns the HTTP response code as the WP_Error code. | ||
| $result['http_status'] = is_numeric( $test_result->get_error_code() ) ? (int) $test_result->get_error_code() : 0; | ||
| } else { | ||
| $result['http_status'] = 200; | ||
| $this->usage_stats( true ); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re
php/class-analytics.php:306—'is_multisite' => is_multisite()in thetrack()send body (anchored here; L306 is outside this PR's diff)Spec §2.3 violation:
is_multisiteis a PHP bool placed in thewp_remote_postbody array, so WP serializesfalse→""andtrue→"1". Spec §2.3 requires real booleans.Recommend JSON-encoding the body so this — plus
format_valid,enabled, and thewizard_setup_submittedflags — serialize as true JSON booleans.