Wire activation-funnel analytics events#1189
Conversation
Builds on the analytics infrastructure (#1187) to emit the activation funnel defined in the spec. Server-side events go through the Analytics component; client-side wizard events go through the JS track() bridge. Server-side: - plugin_activated (step 1) with install-type detection (fresh_install / reactivation / upgrade / downgrade) from the db_version install marker + version compare, emitted on the next admin load via a transient. Persists _cloudinary_last_active on deactivation to derive days_since_last_active. - connection_test_result (3d) in rest_test_connection. - wizard_setup_submitted (5) in rest_save_wizard. - first_sync_started (8, one-time) in Push_Sync::process_assets. - first_api_consumption (9, one-time) via the cloudinary_uploaded_asset action. Client-side (wizard.js -> /events bridge): wizard_started, wizard_signup_clicked, wizard_connect_viewed, credentials_entry_started, credentials_format_validated, connection_test_started, wizard_settings_viewed, wizard_setting_toggled, wizard_completed, wizard_dashboard_clicked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hezzy-cloudinary
left a comment
There was a problem hiding this comment.
Activation-funnel analytics review. Most notes are inline.
Note: the two §2.3 boolean-serialization comments target php/class-analytics.php:306 (is_multisite() in the send body) and :399 (sanitize_text_field in the REST bridge). Both lines are unchanged code outside this PR's diff, so GitHub can't thread them there — they're anchored to the nearest changed line (L200) with a Re: pointer.
| 'activation_funnel', | ||
| 9, | ||
| array( | ||
| 'api_endpoint' => 'upload', |
There was a problem hiding this comment.
Re php/class-analytics.php:306 — 'is_multisite' => is_multisite() in the track() send body (anchored here; L306 is outside this PR's diff)
Spec §2.3 violation: is_multisite is a PHP bool placed in the wp_remote_post body array, so WP serializes false→"" and true→"1". Spec §2.3 requires real booleans.
Recommend JSON-encoding the body so this — plus format_valid, enabled, and the wizard_setup_submitted flags — serialize as true JSON booleans.
| 'activation_funnel', | ||
| 9, | ||
| array( | ||
| 'api_endpoint' => 'upload', |
There was a problem hiding this comment.
Re php/class-analytics.php:399 — sanitize_text_field( (string) $value ) in the REST bridge (anchored here; L399 is outside this PR's diff)
Same §2.3 issue from the bridge: sanitize_text_field( (string) $value ) flattens JS true/false to "1"/"". Preserve booleans once the wire format is decided.
| 'activation_funnel', | ||
| 9, | ||
| array( | ||
| 'api_endpoint' => 'upload', |
There was a problem hiding this comment.
Missing http_status (spec §3 step 9). Thread the response code from $result.
Also api_endpoint is hardcoded to 'upload', but the activation signal is first successful upload or explicit (§3/§6) — derive it from $result or confirm explicit can't be first.
| if ( $analytics ) { | ||
| $success = 'connection_success' === $result['type']; | ||
| $analytics->track( | ||
| 'connection_test_result', |
There was a problem hiding this comment.
connection_test_result missing http_status (spec §3 step 3d, which requires status/error_type/http_status/attempt_number).
Also L165 reads $result['type'] unguarded, before track()'s try/catch — guard with isset() in case a transport-level failure returns no type.
| $analytics = $this->plugin->get_component( 'analytics' ); | ||
| if ( $analytics ) { | ||
| $analytics->track( | ||
| 'wizard_setup_submitted', |
There was a problem hiding this comment.
wizard_setup_submitted missing http_status (spec §3 step 5). Also status is hardcoded 'success' — confirm no failure branch should emit 'error'.
|
|
||
| // Activation funnel: first sync started (emitted once). | ||
| $analytics = $this->plugin->get_component( 'analytics' ); | ||
| if ( $analytics && ! get_option( '_cloudinary_first_sync_emitted' ) ) { |
There was a problem hiding this comment.
first_sync_started missing trigger param (auto/manual; spec §3 step 8).
The flag is also global, not per-account — spec defines this as one-time per account, so a second cloud never re-emits. Same for _cloudinary_first_api_emitted. Key by cloud_name or clear on disconnect, or note the deviation.
Also confirm count( $ids ) is the full first sync, not one thread/batch.
| case 1: | ||
| this.hide( this.back ); | ||
| this.unlockNext(); | ||
| Analytics.track( 'wizard_started', {}, 'activation_funnel', 2 ); |
There was a problem hiding this comment.
wizard_started missing entry_point (auto_redirect/menu; spec §3 step 2). Also fires on back-navigation to step 1 — guard if a single entry per session is intended.
| this.debounceConnect = setTimeout( () => { | ||
| const valid = this.evaluateConnectionString( value ); | ||
| Analytics.track( | ||
| 'credentials_format_validated', |
There was a problem hiding this comment.
credentials_format_validated missing invalid_reason (spec §3 step 3b). Load-bearing for the credential-friction focus; evaluateConnectionString() should expose the failure reason.
| return; | ||
| } | ||
| Analytics.track( | ||
| 'wizard_completed', |
There was a problem hiding this comment.
wizard_completed missing time_to_complete_sec (spec §3 step 6; impl-plan §5 calls it out). Record a timestamp at wizard_started and diff here.
Phase 1 POC, PR #2 of 2 — wires the activation-funnel events on top of the analytics infrastructure from #1187. See the POC definition and implementation plan.
Approach
Server-side events emit directly through the
Analyticscomponent; client-side wizard events go through the JStrack()bridge → the internal/eventsREST route (which enriches them with the server-side param envelope).Server-side
plugin_activated(step 1) — install-type detection inAnalytics::stash_activation()(called fromUtils::install):fresh_install/reactivation/upgrade/downgrade, derived from thedb_versioninstall marker +version_compare. Stashed in a transient and emitted on the next admin load (so full mandatory params +session_idare present), then cleared. A newregister_deactivation_hookpersists_cloudinary_last_activesodays_since_last_activecan be computed on reactivation. Params:activation_type,previous_version,new_version,days_since_last_active.connection_test_result(3d) — inrest_test_connection(status,error_type,attempt_number).wizard_setup_submitted(5) — inrest_save_wizard(media_library,non_media,advanced,status).first_sync_started(8, one-time) — inPush_Sync::process_assets(asset_count).first_api_consumption(9, one-time) — via the existingcloudinary_uploaded_assetaction; emitted on the first successful upload, then suppressed (api_endpoint,asset_type).Client-side (
wizard.js):wizard_started,wizard_signup_clicked,wizard_connect_viewed,credentials_entry_started,credentials_format_validated,connection_test_started,wizard_settings_viewed,wizard_setting_toggled,wizard_completed,wizard_dashboard_clicked.Notes:
first_sync_started,first_api_consumption) are guarded bywp_optionflags.connection_test_resultis server-side (richer error info); the client forwardsattempt_numberon the test request so the server event carries it.track()is now lazy-init so call-sites are order-independent. Still fail-silent end-to-end; nothing emits in production untilcloudinary_analytics_enabledis on (default true) and an event fires.QA notes
plugin_activatedwithactivation_type=fresh_install,previous_versionabsent.activation_type=reactivationwithdays_since_last_active. Bump/lower.versionto exerciseupgrade/downgrade.cloudinary/v1/events),connection_test_resultcarriesattempt_number,wizard_setup_submittedfires on save.first_sync_startedonce; first successful upload →first_api_consumptiononce (re-syncing does not re-fire either).npm run lint:js,npm run lint:php,npm run buildall pass.Test matrix: WP 5.6 / 6.9.x / 7.0, PHP 7.4 / 8.x, single + multisite, classic + block.