Skip to content
Draft
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
27 changes: 19 additions & 8 deletions php/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -488,16 +488,27 @@ protected function save_settings( $submission, $data ) {

$analytics = $this->plugin->get_component( 'analytics' );
if ( $analytics ) {
$analytics->track(
'settings_saved',
'settings',
null,
array(
'page' => $submission,
'changed_keys' => $changed_keys,
)
$params = array(
'page' => $submission,
'changed_keys' => $changed_keys,
);

// Values only for allowlisted feature settings; secrets
// and free text never leave the site. Read back after the
// save so the value reflects any save filters.
$feature_values = array();
foreach ( $changed_keys as $key ) {
$normalized = $analytics->normalize_feature_value( $submission, $key, $this->settings->get_value( $key ) );
if ( null !== $normalized ) {
$feature_values[ $key ] = $normalized;
}
}
if ( ! empty( $feature_values ) ) {
$params['changed_values'] = $feature_values;
}

$analytics->track( 'settings_saved', 'settings', null, $params );

if ( 'gallery' === $submission ) {
$this->track_gallery_configured( $analytics, $data );
}
Expand Down
177 changes: 177 additions & 0 deletions php/class-analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public function __construct( Plugin $plugin ) {
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( 'admin_init', array( $this, 'maybe_send_feature_snapshot' ) );
add_action( 'cloudinary_uploaded_asset', array( $this, 'maybe_first_api_consumption' ), 10, 2 );
}

Expand All @@ -75,6 +76,182 @@ public function __construct( Plugin $plugin ) {
const LAST_ACTIVE = '_cloudinary_last_active';
const FIRST_API_FLAG = '_cloudinary_first_api_emitted';

/**
* Option key marking the feature-config snapshot as sent (per cloud_name).
*/
const FEATURE_SNAPSHOT_FLAG = '_cloudinary_feature_snapshot_emitted';

/**
* Allowlist of feature settings whose values may be emitted, per settings
* page, mapped to how each value is normalised before it leaves the site.
*
* Anything not listed here is never sent as a value. Free-text fields are
* reduced to a presence boolean, since they can hold public IDs, overlay
* text or URLs.
*
* - bool: `on`/`off` toggle → true/false.
* - int: numeric field → int.
* - string: select/radio enum → string.
* - css_length: free-text CSS length → the length, or dropped when it is
* anything else.
* - presence: free text → whether it is non-empty.
* - crops: per-size crop map → number of sizes with a custom crop.
*/
const FEATURE_SETTINGS = array(
'image_settings' => array(
'image_delivery' => 'bool',
'image_optimization' => 'bool',
'image_format' => 'string',
'image_quality' => 'string',
'image_freeform' => 'presence',
'svg_support' => 'bool',
'crop_sizes' => 'crops',
),
'lazy_loading' => array(
'use_lazy_load' => 'bool',
'lazy_threshold' => 'css_length',
'lazy_placeholder' => 'string',
'lazy_animate' => 'bool',
'dpr' => 'string',
),
'responsive' => array(
'enable_breakpoints' => 'bool',
'pixel_step' => 'int',
'breakpoints' => 'int',
'max_width' => 'int',
'min_width' => 'int',
),
'video_settings' => array(
'video_delivery' => 'bool',
'video_player' => 'string',
'adaptive_streaming' => 'bool',
'adaptive_streaming_mode' => 'string',
'video_controls' => 'bool',
'video_loop' => 'bool',
'video_autoplay_mode' => 'string',
'video_optimization' => 'bool',
'video_format' => 'string',
'video_quality' => 'string',
'video_freeform' => 'presence',
),
);

/**
* Normalises a feature setting value for emission.
*
* @param string $page The settings page slug the setting belongs to.
* @param string $key The setting slug.
* @param mixed $value The raw setting value.
*
* @return bool|int|string|null The normalised value, or null when the
* setting is not allowlisted for that page or
* its value is missing or unusable.
*/
public function normalize_feature_value( $page, $key, $value ) {
if ( ! isset( self::FEATURE_SETTINGS[ $page ][ $key ] ) || null === $value ) {
return null;
}

switch ( self::FEATURE_SETTINGS[ $page ][ $key ] ) {
case 'bool':
return 'on' === $value || true === $value;
case 'int':
return is_numeric( $value ) ? (int) $value : null;
case 'css_length':
$value = is_string( $value ) ? trim( $value ) : '';

return preg_match( '/^\d+(\.\d+)?(px|%|r?em|vh|vw)?$/', $value ) ? $value : null;
case 'presence':
return is_string( $value ) && '' !== trim( $value );
case 'crops':
// Each size maps to '' (default crop), '--' (crop disabled) or
// a custom transformation string.
if ( ! is_array( $value ) ) {
return 0;
}

return count(
array_filter(
$value,
static function ( $crop ) {
return is_string( $crop ) && '' !== trim( $crop ) && '--' !== $crop;
}
)
);
default:
return is_scalar( $value ) ? sanitize_text_field( (string) $value ) : null;
}
}

/**
* Emits the one-time `feature_config_snapshot` adoption baseline.
*
* Captures the full state of the delivery features, including those left
* at their defaults, which `settings_saved` never reports. Sent on the
* first admin page load by an administrator once the site is connected,
* then suppressed per cloud_name. That covers wizard setups, sites
* connected through the `CLOUDINARY_CONNECTION_STRING` constant, existing
* installs upgrading to this version, and account switches.
*
* @return void
*/
public function maybe_send_feature_snapshot() {
// `admin_init` also runs for admin-ajax requests, including logged-out
// ones, and for non-admin users visiting their profile.
if ( wp_doing_ajax() || ! Utils::user_can( 'analytics', 'manage_options' ) || ! $this->is_enabled() ) {
return;
}

$connect = $this->plugin->get_component( 'connect' );
if ( ! $connect || ! $connect->is_connected() ) {
return;
}

$cloud = (string) $connect->get_cloud_name();
if ( '' === $cloud ) {
return;
}

// Autoloaded, so this check costs no query on later admin loads.
$sent = get_option( self::FEATURE_SNAPSHOT_FLAG );
if ( $sent === $cloud ) {
return;
}

if ( false === $sent ) {
// Only one of several concurrent first requests sends: add_option()
// upserts, and a request writing the same cloud name as an earlier
// one changes no row, so add_option() returns false for it.
if ( ! add_option( self::FEATURE_SNAPSHOT_FLAG, $cloud, '', true ) ) {
return;
}
} else {
// Account switched.
update_option( self::FEATURE_SNAPSHOT_FLAG, $cloud, true );
}

$this->track( 'feature_config_snapshot', 'features', null, $this->get_feature_snapshot() );
}

/**
* Reads the current, normalised value of every allowlisted feature setting.
*
* @return array
*/
public function get_feature_snapshot() {
$snapshot = array();
foreach ( self::FEATURE_SETTINGS as $page => $settings ) {
foreach ( array_keys( $settings ) as $key ) {
$value = $this->normalize_feature_value( $page, $key, $this->plugin->settings->get_value( $key ) );
if ( null !== $value ) {
$snapshot[ $key ] = $value;
}
}
}

return $snapshot;
}

/**
* Records the activation type on plugin activation (funnel step 1).
*
Expand Down
2 changes: 2 additions & 0 deletions php/class-deactivation.php
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@ protected function cleanup_options() {
'_cld_disable_http_upload',
Report::REPORT_KEY,
Media::GLOBAL_VIDEO_TRANSFORMATIONS,
Analytics::FIRST_API_FLAG,
Analytics::FEATURE_SNAPSHOT_FLAG,
self::CLEANING_KEY,
)
);
Expand Down
34 changes: 33 additions & 1 deletion tests/e2e/features-analytics.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const { test, expect } = require( './fixtures' );
/**
* Internal dependencies
*/
const { wpEvalFile } = require( './utils/wizard' );
const { wpCli, wpEvalFile } = require( './utils/wizard' );
const { fakeCloudinaryConnected } = require( './utils/connection' );
const {
clearAnalyticsEvents,
Expand Down Expand Up @@ -82,3 +82,35 @@ test.describe( 'Extensions & gallery analytics', () => {
expect( events[ 0 ].media_count ).toBe( 2 );
} );
} );

// @serial: the snapshot's one-time flag is shared by the whole site, so a
// parallel worker's admin load could send it into that worker's log instead.
// The sending rules are covered by PHPUnit; this checks the admin wiring.
test.describe( 'Feature config snapshot', { tag: '@serial' }, () => {
test.beforeEach( () => {
fakeCloudinaryConnected();
// Earlier admin loads already sent the snapshot for this cloud_name,
// so reset the one-time flag to simulate a fresh setup.
wpCli( [ 'option', 'delete', '_cloudinary_feature_snapshot_emitted' ] );
clearAnalyticsEvents();
} );

test( 'the first admin load once connected emits feature_config_snapshot once', async ( {
admin,
} ) => {
await admin.visitAdminPage( 'admin.php', 'page=cloudinary' );
await admin.visitAdminPage( 'admin.php', 'page=cloudinary' );

const events = findAnalyticsEvents(
readAnalyticsEvents(),
'feature_config_snapshot'
);
expect( events.length ).toBe( 1 );
expect( events[ 0 ].event_category ).toBe( 'features' );

// Default values are reported even though they were never saved.
expect( typeof events[ 0 ].use_lazy_load ).toBe( 'boolean' );
expect( typeof events[ 0 ].pixel_step ).toBe( 'number' );
expect( typeof events[ 0 ].image_format ).toBe( 'string' );
} );
} );
64 changes: 64 additions & 0 deletions tests/e2e/settings-analytics.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,72 @@ test.describe( 'Settings & navigation analytics', () => {
expect( savedEvents.length ).toBe( 1 );
expect( savedEvents[ 0 ].page ).toBe( 'image_settings' );
expect( savedEvents[ 0 ].changed_keys ).toContain( 'image_quality' );
expect( savedEvents[ 0 ].changed_values.image_quality ).toBe(
nextValue
);
} );

// One feature page each, flipping a field no other spec writes to, so
// parallel workers can't change it between this test's read and save.
for ( const { page: slug, tag, field, values, expected } of [
{
page: 'video_settings',
tag: 'select',
field: 'video_quality',
values: [ 'auto', '80' ],
expected: ( value ) => value,
},
{
page: 'lazy_loading',
tag: 'select',
field: 'dpr',
values: [ '2X', 'max' ],
expected: ( value ) => value,
},
{
page: 'responsive',
tag: 'input',
field: 'breakpoints',
values: [ '10', '12' ],
expected: ( value ) => Number( value ),
},
] ) {
test( `saving the ${ slug } page emits settings_saved with its values`, async ( {
admin,
page,
} ) => {
await admin.visitAdminPage(
'admin.php',
`page=cloudinary_${ slug }`
);

// Set through the DOM: some of these fields are hidden until
// their feature is toggled on, but are still submitted.
const input = page.locator( `${ tag }[name$="[${ field }]"]` );
const current = await input.inputValue();
const nextValue =
values[ 0 ] === current ? values[ 1 ] : values[ 0 ];
await input.evaluate( ( el, value ) => {
el.value = value;
el.dispatchEvent( new Event( 'input', { bubbles: true } ) );
el.dispatchEvent( new Event( 'change', { bubbles: true } ) );
}, nextValue );

await page.locator( SEL.saveButton ).click();
await page.waitForLoadState( 'networkidle' );

const savedEvents = findAnalyticsEvents(
readAnalyticsEvents(),
'settings_saved'
);
expect( savedEvents.length ).toBe( 1 );
expect( savedEvents[ 0 ].page ).toBe( slug );
expect( savedEvents[ 0 ].changed_values[ field ] ).toBe(
expected( nextValue )
);
} );
}

test( 'dismissing an admin notice emits notice_dismissed', async ( {
admin,
page,
Expand Down
Loading
Loading