<?php
/**
* Analytics class for Cloudinary.
*
* Phase 1 POC: custom-events framework that emits activation-funnel events to
* the Cloudinary analytics collector. This component provides the transport
* (server-side, fail-silent), the global parameter envelope, and a REST bridge
* for client-side events. The funnel events themselves are wired in a later PR.
*
* @package Cloudinary
*/
namespace Cloudinary;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Class Analytics.
*
* Sends custom analytics events to the Cloudinary custom-events collector.
*
* @package Cloudinary
*/
class Analytics {
/**
* Holds the plugin instance.
*
* @var Plugin
*/
protected $plugin;
/**
* Memoized per-request session identifier.
*
* @var string|null
*/
protected $session_id = null;
/**
* The internal REST route that the client-side bridge posts events to.
*
* @var string
*/
protected static $rest_route = 'events';
/**
* Constant source value attached to every event.
*
* @var string
*/
const SOURCE = 'wordpress_plugin';
/**
* Initiate the analytics component.
*
* @param Plugin $plugin Instance of the plugin.
*/
public function __construct( Plugin $plugin ) {
$this->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',
'http_status' => 200,
'asset_type' => $asset_type,
)
);
}
/**
* Whether analytics emission is enabled.
*
* Master switch so the transport can be disabled site-wide without removing
* any instrumentation.
*
* @return bool
*/
public function is_enabled() {
/**
* Filter whether the plugin emits custom analytics events.
*
* @hook cloudinary_analytics_enabled
* @since 3.3.5
*
* @param bool $enabled Whether analytics are enabled.
*
* @return bool
*/
return (bool) apply_filters( 'cloudinary_analytics_enabled', true );
}
/**
* Tracks a custom analytics event.
*
* Fail-silent: a collector outage or any error never affects wp-admin.
*
* @param string $event_name The snake_case event identifier.
* @param string $category The event category (funnel/category).
* @param int|null $funnel_step Ordinal step within a funnel, or null.
* @param array $params Event-specific flat params.
*
* @return void
*/
public function track( $event_name, $category, $funnel_step = null, $params = array() ) {
if ( empty( $event_name ) || ! $this->is_enabled() ) {
return;
}
try {
$event = array_merge(
$this->base_params(),
array(
'event_id' => wp_generate_uuid4(),
'event_name' => $event_name,
'event_category' => $category,
'event_timestamp' => gmdate( 'Y-m-d\TH:i:s\Z' ),
),
is_array( $params ) ? $params : array()
);
if ( null !== $funnel_step ) {
$event['funnel_step'] = (int) $funnel_step;
}
$this->dispatch( $event );
} catch ( \Throwable $e ) {
$this->log_silent( $e );
}
}
/**
* Dispatches the event to the collector without blocking the request.
*
* @param array $event The full event payload (flat key/value pairs).
*
* @return void
*/
protected function dispatch( array $event ) {
if ( ! defined( 'CLOUDINARY_ENDPOINTS_ANALYTICS' ) ) {
return;
}
wp_remote_post(
CLOUDINARY_ENDPOINTS_ANALYTICS,
array(
'timeout' => 1,
'blocking' => false,
// 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 ),
)
);
}
/**
* Builds the global parameter envelope attached to every event.
*
* Mandatory params are always present; contextual params (cloud_name, plan)
* are added once a Cloudinary account is connected.
*
* @return array
*/
protected function base_params() {
$params = array(
'source' => self::SOURCE,
'plugin_version' => $this->plugin->version,
'wp_version' => get_bloginfo( 'version' ),
'php_version' => PHP_VERSION,
'site_id' => hash( 'sha256', home_url() ),
'session_id' => $this->get_session_id(),
'user_role' => $this->get_user_role(),
'is_multisite' => is_multisite(),
);
$connect = $this->plugin->get_component( 'connect' );
if ( $connect && $connect->is_connected() ) {
$cloud_name = $connect->get_cloud_name();
if ( ! empty( $cloud_name ) ) {
$params['cloud_name'] = $cloud_name;
}
$plan = $connect->get_usage_stat( 'plan' );
if ( ! empty( $plan ) ) {
$params['plan'] = $plan;
}
}
return $params;
}
/**
* Per-admin-session identifier, derived from the hashed WP login token.
*
* Returns an empty string in non-interactive (cron/async) contexts.
*
* @return string
*/
protected function get_session_id() {
if ( null !== $this->session_id ) {
return $this->session_id;
}
$this->session_id = '';
if ( function_exists( 'wp_get_session_token' ) ) {
$token = wp_get_session_token();
if ( ! empty( $token ) ) {
$this->session_id = hash( 'sha256', $token );
}
}
return $this->session_id;
}
/**
* The primary WordPress role of the acting user.
*
* @return string
*/
protected function get_user_role() {
$user = wp_get_current_user();
if ( $user && ! empty( $user->roles ) ) {
return (string) reset( $user->roles );
}
return '';
}
/**
* Registers the client-side event bridge endpoint.
*
* @param array $endpoints The registered endpoints.
*
* @return array
*/
public function rest_endpoints( $endpoints ) {
$endpoints[ self::$rest_route ] = array(
'method' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rest_track' ),
'args' => array(),
'permission_callback' => function () {
return Utils::user_can( 'analytics', 'manage_options' );
},
);
return $endpoints;
}
/**
* Handles a client-side event, enriching it with the server-side envelope.
*
* @param WP_REST_Request $request The REST request.
*
* @return WP_REST_Response
*/
public function rest_track( WP_REST_Request $request ) {
$event_name = sanitize_key( $request->get_param( 'event_name' ) );
$category = sanitize_key( $request->get_param( 'event_category' ) );
$funnel = $request->get_param( 'funnel_step' );
$params = $request->get_param( 'params' );
$clean = array();
if ( is_array( $params ) ) {
foreach ( $params as $key => $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 );
}
}
}
if ( ! empty( $event_name ) ) {
$this->track(
$event_name,
$category,
is_numeric( $funnel ) ? (int) $funnel : null,
$clean
);
}
return rest_ensure_response( array( 'ok' => true ) );
}
/**
* Exposes the analytics config to the client-side bridge via cldData.
*
* @return void
*/
public function enqueue_script_data() {
$this->plugin->add_script_data(
'analytics',
array(
'endpoint' => Utils::rest_url( REST_API::BASE . '/' . self::$rest_route ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'enabled' => $this->is_enabled(),
)
);
}
/**
* Emits a one-off smoke-test event when explicitly enabled.
*
* Used during the POC to validate the collector path end-to-end. Off by
* default and throttled so it never floods the collector.
*
* @return void
*/
public function maybe_send_smoke_event() {
/**
* Filter whether the analytics smoke-test event is emitted.
*
* @hook cloudinary_analytics_smoke_test
* @since 3.3.5
*
* @param bool $enabled Whether to emit the smoke-test event.
*
* @return bool
*/
if ( ! apply_filters( 'cloudinary_analytics_smoke_test', false ) ) {
return;
}
$throttle_key = '_cloudinary_analytics_smoke';
if ( get_transient( $throttle_key ) ) {
return;
}
set_transient( $throttle_key, true, 5 * MINUTE_IN_SECONDS );
$this->track( 'poc_smoke_test', 'poc' );
}
/**
* Logs an error silently (only when debugging) without surfacing it.
*
* @param \Throwable $error The caught error.
*
* @return void
*/
protected function log_silent( $error ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( 'Cloudinary analytics: ' . $error->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
}