From 018fcc18b0b6408528ffc535da91ff5c8c90e361 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Tue, 21 Jul 2026 09:22:29 +0200 Subject: [PATCH 1/6] Pass version explicitly to the release GH action --- .github/workflows/deploy-to-wp-org.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-to-wp-org.yml b/.github/workflows/deploy-to-wp-org.yml index 94c4bc7e8..94658c6f1 100644 --- a/.github/workflows/deploy-to-wp-org.yml +++ b/.github/workflows/deploy-to-wp-org.yml @@ -78,15 +78,21 @@ jobs: - name: Prepare build directory run: npx grunt prepare + # Read once so both the tag check and the deploy step use the exact same value, rather + # than letting the deploy action derive its own version from GITHUB_REF - that only works + # when triggered from a tag push, and yields a bogus value like "refs/heads/master" (which + # SVN then can't use as a tag path) on a workflow_dispatch run against a branch. + - name: Read plugin version + run: echo "PLUGIN_VERSION=$(cat .version | tr -d '[:space:]')" >> "$GITHUB_ENV" + # Ensure the version in the .version file matches the tag of the release. # Skipped for manual runs, which may not be run against a release tag. - name: Verify version matches tag if: github.event_name == 'release' run: | TAG="${GITHUB_REF_NAME#v}" - FILE_VERSION=$(cat .version | tr -d '[:space:]') - if [ "$TAG" != "$FILE_VERSION" ]; then - echo "::error::Tag $TAG does not match .version $FILE_VERSION" + if [ "$TAG" != "$PLUGIN_VERSION" ]; then + echo "::error::Tag $TAG does not match .version $PLUGIN_VERSION" exit 1 fi @@ -102,6 +108,7 @@ jobs: env: BUILD_DIR: 'build' SLUG: 'cloudinary-image-management-and-manipulation-in-the-cloud-cdn' + VERSION: ${{ env.PLUGIN_VERSION }} # Use secrets to authenticate with WP.org. SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} From 603fe2f8973be41028c10d9fc927e0aea3d7dda0 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Fri, 31 Jul 2026 09:50:13 +0200 Subject: [PATCH 2/6] Instrument connection, sync, settings, media, cache, features, and deactivation analytics events Wires the remaining 7 event categories from the analytics tracking spec on top of the existing WPP-1210 custom-events framework: connection management, asset sync, settings & navigation, media & asset actions, non-media cache, extensions & gallery, and deactivation. All call sites reuse Analytics::track() / Analytics.track() and were live-verified against wp-env via WP-CLI/REST dispatch. Adds a permanent e2e analytics-capture mu-plugin and two Playwright specs covering connection and deactivation events. Co-Authored-By: Claude Sonnet 5 --- .wp-env/mu-plugins/analytics-capture.php | 79 ++++++++++++ php/assets/class-rest-assets.php | 80 ++++++++++++ php/class-admin.php | 117 +++++++++++++++++- php/class-assets.php | 35 ++++++ php/class-connect.php | 106 +++++++++++++++- php/class-deactivation.php | 39 +++++- php/class-special-offer.php | 11 +- php/class-sync.php | 21 +++- php/sync/class-push-sync.php | 18 +++ php/sync/class-sync-queue.php | 147 +++++++++++++++++++++++ src/js/components/extensions.js | 6 + src/js/components/special-offer.js | 27 +++++ src/js/deactivate.js | 15 ++- src/js/main.js | 4 +- tests/e2e/connection-analytics.spec.js | 86 +++++++++++++ tests/e2e/deactivation-analytics.spec.js | 99 +++++++++++++++ tests/e2e/utils/analytics.js | 61 ++++++++++ tests/e2e/utils/connection.js | 31 +++++ 18 files changed, 961 insertions(+), 21 deletions(-) create mode 100644 .wp-env/mu-plugins/analytics-capture.php create mode 100644 src/js/components/special-offer.js create mode 100644 tests/e2e/connection-analytics.spec.js create mode 100644 tests/e2e/deactivation-analytics.spec.js create mode 100644 tests/e2e/utils/analytics.js diff --git a/.wp-env/mu-plugins/analytics-capture.php b/.wp-env/mu-plugins/analytics-capture.php new file mode 100644 index 000000000..efaef7497 --- /dev/null +++ b/.wp-env/mu-plugins/analytics-capture.php @@ -0,0 +1,79 @@ +assets->media; $attachment_id = $request->get_param( 'ID' ); $type = $media->get_resource_type( $attachment_id ); + $analytics = get_plugin_instance()->get_component( 'analytics' ); $return = array(); + $saved = false; // Save transformations if present. $transformations = $request->get_param( 'transformations' ); @@ -117,6 +120,19 @@ public function rest_save_asset( $request ) { if ( isset( $transformations ) ) { $result = $this->save_transformation_type( $attachment_id, $transformations, $type, 'transformations' ); $return = array_merge( $return, $result ); + $saved = true; + + if ( $analytics ) { + $analytics->track( + 'transformation_applied', + 'media', + null, + array( + 'scope' => 'asset', + 'transformation_count' => $this->count_transformation_qualifiers( $result['transformations'] ), + ) + ); + } } // Save text overlay even if empty (allow clearing). @@ -125,6 +141,7 @@ public function rest_save_asset( $request ) { if ( isset( $text_overlay ) && array_key_exists( 'transformation', (array) $text_overlay ) ) { $result = $this->save_transformation_type( $attachment_id, $text_overlay['transformation'], $type, 'text_overlay', $text_overlay ); $return = array_merge( $return, $result ); + $saved = true; } // Save image overlay even if empty (allow clearing). @@ -133,11 +150,44 @@ public function rest_save_asset( $request ) { if ( isset( $image_overlay ) && array_key_exists( 'transformation', (array) $image_overlay ) ) { $result = $this->save_transformation_type( $attachment_id, $image_overlay['transformation'], $type, 'image_overlay', $image_overlay ); $return = array_merge( $return, $result ); + $saved = true; + } + + if ( $saved && $analytics ) { + $analytics->track( + 'asset_edited', + 'media', + null, + array( + 'asset_id' => (int) $attachment_id, + 'asset_type' => $type, + ) + ); } return rest_ensure_response( $return ); } + /** + * Counts the comma/slash-separated qualifiers in a transformation string. + * + * @param string $transformation The cleaned transformation string. + * + * @return int + */ + protected function count_transformation_qualifiers( $transformation ) { + if ( empty( $transformation ) ) { + return 0; + } + + $count = 0; + foreach ( array_filter( explode( '/', $transformation ) ) as $segment ) { + $count += count( array_filter( explode( ',', $segment ) ) ); + } + + return $count; + } + /** * Shared helper to save a transformation type (main, text overlay, image overlay). * @@ -222,6 +272,7 @@ public function rest_purge_all( $request ) { $count = $request->get_param( 'count' ); $clean = $this->assets->clean_path( $parent_url ); $parent = $this->assets->get_param( $clean ); + $analytics = get_plugin_instance()->get_component( 'analytics' ); $result = array( 'total' => 0, 'pending' => count( $this->assets->get_asset_parents() ), @@ -244,6 +295,10 @@ public function rest_purge_all( $request ) { $result['total'] = 0; $result['pending'] = 0; $result['percent'] = 100; + + if ( $analytics ) { + $analytics->track( 'asset_cache_purged', 'cache', null, array( 'scope' => $clean ) ); + } } elseif ( false === $count ) { $data = array( 'public_id' => null, @@ -260,6 +315,10 @@ public function rest_purge_all( $request ) { $result['total'] = 0; $result['pending'] = 0; $result['percent'] = 100; + + if ( $analytics ) { + $analytics->track( 'all_cache_purged', 'cache' ); + } } return rest_ensure_response( $result ); @@ -280,6 +339,11 @@ public function rest_get_caches( $request ) { $current_page = $page ? $page : 1; $data = $this->get_assets( $parent->ID, $search, $current_page ); + $analytics = get_plugin_instance()->get_component( 'analytics' ); + if ( $analytics ) { + $analytics->track( 'cache_items_viewed', 'cache', null, array( 'cache_point' => (string) $url ) ); + } + return rest_ensure_response( $data ); } @@ -308,6 +372,22 @@ public function rest_handle_state( $request ) { global $wpdb; $ids = $request['ids']; $state = $request['state']; + + if ( 'delete' !== $state ) { + $analytics = get_plugin_instance()->get_component( 'analytics' ); + if ( $analytics ) { + $analytics->track( + 'cache_items_toggled', + 'cache', + null, + array( + 'enabled' => 'enable' === strtolower( $state ), + 'item_count' => count( $ids ), + ) + ); + } + } + foreach ( $ids as $id ) { $where = array( 'post_id' => $id, diff --git a/php/class-admin.php b/php/class-admin.php index 422218e41..f54d1abeb 100644 --- a/php/class-admin.php +++ b/php/class-admin.php @@ -138,6 +138,11 @@ public function rest_dismiss_notice( WP_REST_Request $request ) { $duration = $request->get_param( 'duration' ); set_transient( $token, true, $duration ); + + $analytics = $this->plugin->get_component( 'analytics' ); + if ( $analytics ) { + $analytics->track( 'notice_dismissed', 'settings', null, array( 'notice_id' => (string) $token ) ); + } } /** @@ -301,7 +306,14 @@ public function render() { $page = $this->get_param( 'current_section' ); } - $this->set_param( 'active_slug', isset( $page['slug'] ) ? $page['slug'] : '' ); + $active_slug = isset( $page['slug'] ) ? $page['slug'] : ''; + $this->set_param( 'active_slug', $active_slug ); + + $analytics = $this->plugin->get_component( 'analytics' ); + if ( $analytics && ! empty( $active_slug ) ) { + $analytics->track( 'settings_page_viewed', 'settings', null, array( 'page' => $active_slug ) ); + } + $setting = $this->init_components( $page, $screen->id ); $this->component = $setting->get_component(); $template = $this->section; @@ -432,9 +444,10 @@ public function init_setting_save() { * @param array $data The data to save. */ protected function save_settings( $submission, $data ) { - $page = $this->settings->get_setting( $submission ); - $errors = array(); - $pending = false; + $page = $this->settings->get_setting( $submission ); + $errors = array(); + $pending = false; + $changed_keys = array(); foreach ( $data as $key => $value ) { $slug = $submission . $page->separator . $key; $current = $this->settings->get_value( $slug ); @@ -448,13 +461,33 @@ protected function save_settings( $submission, $data ) { $this->add_admin_notice( $result->get_error_code(), $result->get_error_message(), $result->get_error_data() ); break; } - $pending = true; + $changed_keys[] = $key; + $pending = true; } if ( empty( $errors ) && true === $pending ) { $results = $this->settings->save(); if ( ! empty( $results ) ) { $this->add_admin_notice( 'error_notice', __( 'Settings updated successfully', 'cloudinary' ), 'success' ); + + $analytics = $this->plugin->get_component( 'analytics' ); + if ( $analytics ) { + $analytics->track( + 'settings_saved', + 'settings', + null, + array( + 'page' => $submission, + 'changed_keys' => $changed_keys, + ) + ); + + if ( 'gallery' === $submission ) { + $this->track_gallery_configured( $analytics, $data ); + } + + $this->maybe_track_global_transformation( $analytics, $submission, $changed_keys ); + } } } else { $this->add_admin_notice( 'error_notice', __( 'No changes to save', 'cloudinary' ), 'success' ); @@ -463,6 +496,80 @@ protected function save_settings( $submission, $data ) { do_action( 'cloudinary_flush_cache' ); } + /** + * Settings slugs (by page) that directly inject transformation qualifiers + * (format, quality, freeform string) into delivered URLs, as opposed to + * plain feature-enablement toggles. + * + * @var array + */ + const GLOBAL_TRANSFORMATION_SLUGS = array( + 'image_settings' => array( 'image_format', 'image_quality', 'image_freeform' ), + 'video_settings' => array( 'video_format', 'video_quality', 'video_freeform' ), + ); + + /** + * Emits `transformation_applied` (scope: global) when a settings save + * changed a global transformation field. + * + * `global-transformations.js` only builds a live preview and never + * persists anything itself, so this is derived from the generic + * settings-save diff rather than a dedicated save action. + * + * @param Analytics $analytics The analytics component. + * @param string $submission The settings page slug that was saved. + * @param array $changed_keys The keys that changed in this save. + * + * @return void + */ + protected function maybe_track_global_transformation( $analytics, $submission, $changed_keys ) { + if ( ! isset( self::GLOBAL_TRANSFORMATION_SLUGS[ $submission ] ) ) { + return; + } + + $matched = array_intersect( $changed_keys, self::GLOBAL_TRANSFORMATION_SLUGS[ $submission ] ); + if ( empty( $matched ) ) { + return; + } + + $analytics->track( + 'transformation_applied', + 'media', + null, + array( + 'scope' => 'global', + 'transformation_count' => count( $matched ), + ) + ); + } + + /** + * Emits `gallery_configured` for a gallery settings save. + * + * The gallery React panel serializes its whole config (including the + * layout mode and selected media) into the `gallery_config` field as a + * JSON string, so `layout`/`media_count` have to be parsed out of it + * rather than read as their own submitted fields. + * + * @param Analytics $analytics The analytics component. + * @param array $data The raw submitted gallery data. + * + * @return void + */ + protected function track_gallery_configured( $analytics, $data ) { + $config = isset( $data['gallery_config'] ) ? json_decode( $data['gallery_config'], true ) : null; + + $analytics->track( + 'gallery_configured', + 'features', + null, + array( + 'layout' => isset( $config['displayProps']['mode'] ) ? $config['displayProps']['mode'] : '', + 'media_count' => isset( $config['mediaAssets'] ) && is_array( $config['mediaAssets'] ) ? count( $config['mediaAssets'] ) : 0, + ) + ); + } + /** * Set an error/notice for a setting. * diff --git a/php/class-assets.php b/php/class-assets.php index afdd8e79f..f20f78346 100644 --- a/php/class-assets.php +++ b/php/class-assets.php @@ -165,6 +165,41 @@ protected function register_hooks() { add_action( 'admin_bar_menu', array( $this, 'admin_bar_cache' ), 100 ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'cloudinary_delete_asset', array( $this, 'purge_parent' ) ); + add_action( 'cloudinary_uploaded_asset', array( $this, 'track_cache_uploaded' ), 10, 2 ); + } + + /** + * Emits `cache_uploaded` when a non-media asset is pushed to Cloudinary. + * + * Hooked to the same action `Analytics::maybe_first_api_consumption()` + * uses for the activation funnel, filtered down to non-media assets. + * Fires once per asset (no batch-level upload wrapper exists to hook + * instead), so `item_count` is always 1. + * + * @param int $attachment_id The attachment ID. + * @param array|\WP_Error $result The upload result. + * + * @return void + */ + public function track_cache_uploaded( $attachment_id, $result ) { + if ( ! self::is_asset_type( $attachment_id ) ) { + return; + } + + $analytics = $this->plugin->get_component( 'analytics' ); + if ( ! $analytics ) { + return; + } + + $analytics->track( + 'cache_uploaded', + 'cache', + null, + array( + 'item_count' => 1, + 'status' => is_wp_error( $result ) ? 'error' : 'success', + ) + ); } /** diff --git a/php/class-connect.php b/php/class-connect.php index bc7e75a4d..2729f2edd 100644 --- a/php/class-connect.php +++ b/php/class-connect.php @@ -291,7 +291,10 @@ public function register_meta( $pages ) { * @return array|WP_Error The data if cleared. */ public function verify_connection( $data ) { - $admin = $this->plugin->get_component( 'admin' ); + $admin = $this->plugin->get_component( 'admin' ); + $analytics = $this->plugin->get_component( 'analytics' ); + $current = $this->plugin->settings->find_setting( 'connect' )->get_value(); + if ( empty( $data['cloudinary_url'] ) ) { delete_option( self::META_KEYS['signature'] ); $admin->add_admin_notice( @@ -302,11 +305,14 @@ public function verify_connection( $data ) { ); $this->plugin->settings->set_param( 'connected', false ); + if ( $analytics && ! empty( $current['cloudinary_url'] ) ) { + $analytics->track( 'connection_disconnected', 'connection' ); + } + return $data; } $data['cloudinary_url'] = str_replace( 'CLOUDINARY_URL=', '', $data['cloudinary_url'] ); - $current = $this->plugin->settings->find_setting( 'connect' )->get_value(); // Same URL, return original data. if ( $current['cloudinary_url'] === $data['cloudinary_url'] ) { @@ -321,6 +327,18 @@ public function verify_connection( $data ) { 'error' ); + if ( $analytics ) { + $analytics->track( + 'connection_string_updated', + 'connection', + null, + array( + 'status' => 'error', + 'error_type' => 'format_mismatch', + ) + ); + } + return $current; } @@ -333,6 +351,19 @@ public function verify_connection( $data ) { 'error' ); + if ( $analytics ) { + $analytics->track( + 'connection_string_updated', + 'connection', + null, + array( + 'status' => 'error', + 'error_type' => $result['type'], + 'http_status' => (int) $result['http_status'], + ) + ); + } + return $current; } @@ -345,6 +376,34 @@ public function verify_connection( $data ) { $this->settings->get_setting( 'signature' )->save_value( md5( $data['cloudinary_url'] ) ); $this->plugin->settings->set_param( 'connected', true ); + if ( $analytics ) { + $analytics->track( + 'connection_string_updated', + 'connection', + null, + array( + 'status' => 'success', + 'error_type' => '', + 'http_status' => (int) $result['http_status'], + ) + ); + + $previous_cloud = ! empty( $current['cloudinary_url'] ) ? wp_parse_url( $current['cloudinary_url'], PHP_URL_HOST ) : ''; + $new_cloud = wp_parse_url( $data['cloudinary_url'], PHP_URL_HOST ); + + if ( ! empty( $previous_cloud ) && $new_cloud !== $previous_cloud ) { + $analytics->track( + 'account_switched', + 'connection', + null, + array( + 'previous_cloud_name' => $previous_cloud, + 'new_cloud_name' => $new_cloud, + ) + ); + } + } + return $data; } @@ -581,6 +640,23 @@ public function check_status() { $status = $this->test_ping(); $this->settings->get_setting( 'status' )->save_value( $status ); + if ( is_wp_error( $status ) ) { + $analytics = $this->plugin->get_component( 'analytics' ); + if ( $analytics ) { + $code = $status->get_error_code(); + $analytics->track( + 'connectivity_check_failed', + 'connection', + null, + array( + 'check_type' => 'ping', + 'http_status' => is_numeric( $code ) ? (int) $code : 0, + 'error_type' => is_numeric( $code ) ? '' : (string) $code, + ) + ); + } + } + return $status; } @@ -1049,6 +1125,20 @@ public static function check_rest_api_connectivity() { ), false ); + + $analytics = $plugin->get_component( 'analytics' ); + if ( $analytics ) { + $analytics->track( + 'connectivity_check_failed', + 'connection', + null, + array( + 'check_type' => 'rest_api', + 'http_status' => isset( $connectivity['http_status'] ) ? (int) $connectivity['http_status'] : 0, + 'error_type' => isset( $connectivity['error_type'] ) ? (string) $connectivity['error_type'] : '', + ) + ); + } } return $connectivity; @@ -1076,23 +1166,27 @@ public static function test_rest_api_connectivity() { if ( is_wp_error( $response ) ) { $result = array( - 'working' => false, - 'message' => sprintf( + 'working' => false, + 'message' => sprintf( /* translators: 1: The WordPress error message. 2: The WordPress error code. */ __( 'The Cloudinary REST API endpoints are not available. Error: %1$s (%2$s)', 'cloudinary' ), $response->get_error_message(), $response->get_error_code() ), + 'http_status' => 0, + 'error_type' => (string) $response->get_error_code(), ); } elseif ( 200 !== wp_remote_retrieve_response_code( $response ) ) { $result = array( - 'working' => false, - 'message' => sprintf( + 'working' => false, + 'message' => sprintf( /* translators: 1: The WordPress error message. 2: The WordPress error code. */ __( 'The Cloudinary REST API endpoints are not available. Error: %1$s (%2$s)', 'cloudinary' ), wp_remote_retrieve_response_message( $response ), wp_remote_retrieve_response_code( $response ) ), + 'http_status' => (int) wp_remote_retrieve_response_code( $response ), + 'error_type' => 'http_error', ); } diff --git a/php/class-deactivation.php b/php/class-deactivation.php index 6edb7fcac..6d3202d79 100644 --- a/php/class-deactivation.php +++ b/php/class-deactivation.php @@ -185,7 +185,7 @@ public function render_connected() { $is_cloudinary_only = 'cld' === $this->plugin->settings->get_value( 'offload' ); ?> -
+