Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cloudinary.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
if ( version_compare( phpversion(), '7.4', '>=' ) ) {
require_once __DIR__ . '/instance.php';
register_activation_hook( __FILE__, array( 'Cloudinary\Utils', 'install' ) );
register_deactivation_hook( __FILE__, array( 'Cloudinary\Analytics', 'record_deactivation' ) );
} else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
if ( defined( 'WP_CLI' ) ) {
WP_CLI::warning( php_version_text() );
Expand Down
2 changes: 1 addition & 1 deletion js/cloudinary.js

Large diffs are not rendered by default.

137 changes: 137 additions & 0 deletions php/class-analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,143 @@ 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;
}
if ( get_option( self::FIRST_API_FLAG ) ) {
return;
}
update_option( self::FIRST_API_FLAG, true, 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(
'api_endpoint' => 'upload',

Copy link
Copy Markdown

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 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.

Copy link
Copy Markdown

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:399sanitize_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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

'asset_type' => $asset_type,
)
);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions php/class-connect.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,21 @@ 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 = 'connection_success' === $result['type'];
$analytics->track(
'connection_test_result',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

'activation_funnel',
3,
array(
'status' => $success ? 'success' : 'error',
'error_type' => $success ? '' : $result['type'],
'attempt_number' => (int) $request->get_param( 'attempt_number' ),
)
);
}

return rest_ensure_response( $result );
}

Expand Down Expand Up @@ -224,6 +239,21 @@ public function rest_save_wizard( WP_REST_Request $request ) {
);
}

$analytics = $this->plugin->get_component( 'analytics' );
if ( $analytics ) {
$analytics->track(
'wizard_setup_submitted',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wizard_setup_submitted missing http_status (spec §3 step 5). Also status is hardcoded 'success' — confirm no failure branch should emit 'error'.

'activation_funnel',
5,
array(
'media_library' => 'on' === $media,
'non_media' => 'on' === $nonmedia,
'advanced' => 'on' === $advanced,
'status' => 'success',
)
);
}

return rest_ensure_response( $this->settings->get_value() );
}

Expand Down
3 changes: 3 additions & 0 deletions php/class-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,9 @@ public static function install() {
// Ensure that the plugin bootstrap is loaded.
get_plugin_instance()->init();

// Record the activation type for analytics before any install/upgrade runs.
Analytics::stash_activation();

$sql = self::get_table_sql();

if ( false === self::table_installed() ) {
Expand Down
12 changes: 12 additions & 0 deletions php/sync/class-push-sync.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ public function process_assets( $attachments = array() ) {
// If a single specified ID, push and return response.
$ids = array_map( 'intval', (array) $attachments );
$thread = $this->plugin->settings->get_param( 'current_sync_thread' );

// Activation funnel: first sync started (emitted once).
$analytics = $this->plugin->get_component( 'analytics' );
if ( $analytics && ! get_option( '_cloudinary_first_sync_emitted' ) ) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

update_option( '_cloudinary_first_sync_emitted', true, false );
$analytics->track(
'first_sync_started',
'activation_funnel',
8,
array( 'asset_count' => count( $ids ) )
);
}
// Handle based on Sync Type.
foreach ( $ids as $attachment_id ) {

Expand Down
7 changes: 7 additions & 0 deletions src/js/components/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const Analytics = {
config: null,

init() {
if ( this.config ) {
return;
}
if (
typeof cldData === 'undefined' ||
! cldData.analytics ||
Expand All @@ -38,6 +41,10 @@ const Analytics = {
category = 'activation_funnel',
funnelStep = null
) {
// Lazy-init so call-sites work regardless of listener order.
if ( ! this.config ) {
this.init();
}
if ( ! this.config || ! this.config.enabled || ! eventName ) {
return;
}
Expand Down
Loading
Loading