diff --git a/.github/actions/prepare-source/action.yml b/.github/actions/prepare-source/action.yml index 7f55e2b8ae..86bcbc005d 100644 --- a/.github/actions/prepare-source/action.yml +++ b/.github/actions/prepare-source/action.yml @@ -4,7 +4,7 @@ runs: using: composite steps: - name: Check out mu-plugins-ext - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: repository: 'Automattic/vip-go-mu-plugins-ext' path: 'vip-go-mu-plugins-ext' diff --git a/.github/workflows/changelog-summary-prod.yml b/.github/workflows/changelog-summary-prod.yml index 54bd5e13f1..059be3ccb7 100644 --- a/.github/workflows/changelog-summary-prod.yml +++ b/.github/workflows/changelog-summary-prod.yml @@ -25,7 +25,7 @@ jobs: egress-policy: audit - name: Check out source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Retrieve tags run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* @@ -61,7 +61,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Setup PHP uses: shivammathur/setup-php@2.30.5 diff --git a/.github/workflows/changelog-summary-staging.yml b/.github/workflows/changelog-summary-staging.yml index 78d3068fef..9299f451dd 100644 --- a/.github/workflows/changelog-summary-staging.yml +++ b/.github/workflows/changelog-summary-staging.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Setup PHP uses: shivammathur/setup-php@2.30.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd23de8109..a643f5de21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: MYSQL_DATABASE: wordpress_test steps: - name: Check out source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: submodules: recursive diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 934ecbc14c..484c87f567 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -29,13 +29,13 @@ jobs: - javascript steps: - name: Checkout repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.26.8 + uses: github/codeql-action/init@v3.26.10 with: languages: ${{ matrix.language }} config-file: ./.github/codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.26.8 + uses: github/codeql-action/analyze@v3.26.10 diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml index 555401de0c..4684f66ee9 100644 --- a/.github/workflows/core-tests.yml +++ b/.github/workflows/core-tests.yml @@ -42,14 +42,14 @@ jobs: echo "PHP_FPM_GID=$(id -g)" >> "${GITHUB_ENV}" - name: Checkout WordPress - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: repository: wordpress/wordpress-develop path: wordpress ref: ${{ steps.version.outputs.latest }} - name: Check out source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: submodules: recursive path: wordpress/src/wp-content/mu-plugins diff --git a/.github/workflows/coverage-develop.yml b/.github/workflows/coverage-develop.yml index 334dbf7c30..5e243fc560 100644 --- a/.github/workflows/coverage-develop.yml +++ b/.github/workflows/coverage-develop.yml @@ -36,7 +36,7 @@ jobs: MYSQL_DATABASE: wordpress_test steps: - name: Check out source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: submodules: recursive diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f82faf6c34..b0bd9c6b99 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -22,7 +22,7 @@ jobs: github.com:443 - name: Check out the source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Review dependencies uses: actions/dependency-review-action@v4.3.4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e092baa4a9..02cbebf42d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,19 +22,19 @@ jobs: contents: write steps: - name: Check out the source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: submodules: recursive path: ${{ env.SOURCE_REPO_PATH }} - name: Check out Automattic/vip-go-mu-plugins-ext - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: repository: Automattic/vip-go-mu-plugins-ext path: ${{ env.EXT_REPO_PATH }} - name: Check out Automattic/vip-go-mu-plugins-built - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: repository: Automattic/vip-go-mu-plugins-built path: ${{ env.TARGET_REPO_PATH }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6e26adf975..514c505afd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -68,7 +68,7 @@ jobs: wordpress.org:443 - name: Check out repository code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: submodules: true @@ -131,7 +131,7 @@ jobs: egress-policy: audit - name: Check out repository code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Setup Node uses: actions/setup-node@v4.0.4 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8d4818baa8..52e42e7788 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Set up PHP uses: shivammathur/setup-php@2.30.5 @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 - name: Setup Node uses: actions/setup-node@v4.0.4 diff --git a/.github/workflows/parsely.yml b/.github/workflows/parsely.yml index 776c7b473e..5c41a6a3bd 100644 --- a/.github/workflows/parsely.yml +++ b/.github/workflows/parsely.yml @@ -25,18 +25,10 @@ jobs: # Oldest version of the parsely plugin - { wp: latest, parsely: '3.5', mode: 'filter_enabled', php: '8.1' } - { wp: latest, parsely: '3.5', mode: 'filter_disabled', php: '8.1' } - - { wp: latest, parsely: '3.5', mode: 'option_enabled', php: '8.1' } - - { wp: latest, parsely: '3.5', mode: 'option_disabled', php: '8.1' } - - { wp: latest, parsely: '3.5', mode: 'filter_and_option_enabled', php: '8.1' } - - { wp: latest, parsely: '3.5', mode: 'filter_and_option_disabled', php: '8.1' } # Latest version of the parsely plugin - { wp: latest, mode: 'filter_enabled', php: '8.1' } - { wp: latest, mode: 'filter_disabled', php: '8.1' } - - { wp: latest, mode: 'option_enabled', php: '8.1' } - - { wp: latest, mode: 'option_disabled', php: '8.1' } - - { wp: latest, mode: 'filter_and_option_enabled', php: '8.1' } - - { wp: latest, mode: 'filter_and_option_disabled', php: '8.1' } services: mysql: image: mysql:8 @@ -50,7 +42,7 @@ jobs: MYSQL_DATABASE: wordpress_test steps: - name: Check out source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: submodules: recursive diff --git a/.github/workflows/search-dev-tools.yml b/.github/workflows/search-dev-tools.yml index 7509e98bef..efdd6503da 100644 --- a/.github/workflows/search-dev-tools.yml +++ b/.github/workflows/search-dev-tools.yml @@ -22,7 +22,7 @@ jobs: contents: write steps: - name: Check out source code - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: token: ${{ secrets.WPCOM_VIP_BOT_TOKEN }} diff --git a/.github/workflows/search-e2e.yml b/.github/workflows/search-e2e.yml index 463d0ea892..1ba4f80948 100644 --- a/.github/workflows/search-e2e.yml +++ b/.github/workflows/search-e2e.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.0 with: submodules: recursive diff --git a/000-vip-init.php b/000-vip-init.php index d30b588c16..a5710ad1e8 100644 --- a/000-vip-init.php +++ b/000-vip-init.php @@ -233,6 +233,31 @@ require_once __DIR__ . '/vip-helpers/class-user-cleanup.php'; require_once __DIR__ . '/vip-helpers/class-wpcomvip-restrictions.php'; +// Load the Telemetry files +// TODO: switch to plain require_once like the above once the telemetry is fully deployed (all files are present) +$require_telemetry_files = [ + __DIR__ . '/telemetry/class-telemetry-system.php', + __DIR__ . '/telemetry/class-tracks.php', + __DIR__ . '/telemetry/class-telemetry-client.php', + __DIR__ . '/telemetry/class-telemetry-event-queue.php', + __DIR__ . '/telemetry/class-telemetry-event.php', + __DIR__ . '/telemetry/tracks/class-tracks-event-dto.php', + __DIR__ . '/telemetry/tracks/class-tracks-event.php', + __DIR__ . '/telemetry/tracks/class-tracks-client.php', +]; + +// If there is a missing file, the loop will break and the telemetry files will not be loaded at all +do { + foreach ( $require_telemetry_files as $file ) { + if ( ! file_exists( $file ) ) { + break; + } + } + foreach ( $require_telemetry_files as $file ) { + require_once $file; + } +} while ( false ); + add_action( 'init', [ WPComVIP_Restrictions::class, 'instance' ] ); //enabled on selected sites for now diff --git a/__tests__/e2e/package-lock.json b/__tests__/e2e/package-lock.json index e03710fa9d..c9218f02b7 100644 --- a/__tests__/e2e/package-lock.json +++ b/__tests__/e2e/package-lock.json @@ -716,9 +716,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.6.0.tgz", - "integrity": "sha512-QyR8d5bmq+eR72TwQDfujwShHMcIrWIYsaQFtXRE58MHPTEKUNxjxvl0yS0qPMds5xbSDWtp7ZpvGFtd7dfMdQ==", + "version": "22.7.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", + "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", "dev": true, "dependencies": { "undici-types": "~6.19.2" diff --git a/package-lock.json b/package-lock.json index 3c7a6260d3..8a9d5f5907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7238,9 +7238,9 @@ "dev": true }, "node_modules/10up-toolkit": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/10up-toolkit/-/10up-toolkit-6.2.2.tgz", - "integrity": "sha512-4dfiIWmWF0M+rHINV14+IZxmQLfonDPKuU3YmxCCAjAsRw79GQIW+YNzMeasaxI760N+zWxPGgizv+GwP2jKOA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/10up-toolkit/-/10up-toolkit-6.3.0.tgz", + "integrity": "sha512-ubRo+y+amPVPw0CYE1sPW3GJicQYUiHUORzTRJO8/nCepLNjCwT3lB7HDGLBvKeSgG9+rF6QK+D5NLe0Ekp5gw==", "dev": true, "dependencies": { "@babel/eslint-parser": "^7.23.3", diff --git a/search/search-dev-tools/package-lock.json b/search/search-dev-tools/package-lock.json index 7775247a1c..18884c704d 100644 --- a/search/search-dev-tools/package-lock.json +++ b/search/search-dev-tools/package-lock.json @@ -9774,9 +9774,9 @@ "license": "MIT" }, "node_modules/preact": { - "version": "10.24.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.0.tgz", - "integrity": "sha512-aK8Cf+jkfyuZ0ZZRG9FbYqwmEiGQ4y/PUO4SuTWoyWL244nZZh7bd5h2APd4rSNDYTBNghg1L+5iJN3Skxtbsw==", + "version": "10.24.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.1.tgz", + "integrity": "sha512-PnBAwFI3Yjxxcxw75n6VId/5TFxNW/81zexzWD9jn1+eSrOP84NdsS38H5IkF/UH3frqRPT+MvuCoVHjTDTnDw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -10436,9 +10436,9 @@ "dev": true }, "node_modules/sass": { - "version": "1.79.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.3.tgz", - "integrity": "sha512-m7dZxh0W9EZ3cw50Me5GOuYm/tVAJAn91SUnohLRo9cXBixGUOdvmryN+dXpwR831bhoY3Zv7rEFt85PUwTmzA==", + "version": "1.79.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.4.tgz", + "integrity": "sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==", "dev": true, "dependencies": { "chokidar": "^4.0.0", @@ -11980,9 +11980,9 @@ } }, "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", + "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", "dev": true, "dependencies": { "@types/estree": "^1.0.5", diff --git a/shared-plugins/two-factor/class-two-factor-core.php b/shared-plugins/two-factor/class-two-factor-core.php index 6a990c87fd..4d798993c4 100644 --- a/shared-plugins/two-factor/class-two-factor-core.php +++ b/shared-plugins/two-factor/class-two-factor-core.php @@ -93,7 +93,7 @@ class Two_Factor_Core { * @since 0.1-dev */ public static function add_hooks( $compat ) { - add_action( 'plugins_loaded', array( __CLASS__, 'load_textdomain' ) ); + add_action( 'init', array( __CLASS__, 'load_textdomain' ) ); add_action( 'init', array( __CLASS__, 'get_providers' ) ); add_action( 'wp_login', array( __CLASS__, 'wp_login' ), 10, 2 ); add_filter( 'wp_login_errors', array( __CLASS__, 'maybe_show_reset_password_notice' ) ); diff --git a/telemetry/README.md b/telemetry/README.md new file mode 100644 index 0000000000..9156160325 --- /dev/null +++ b/telemetry/README.md @@ -0,0 +1,67 @@ +# VIP Telemetry Library + +## Tracks + +Tracks is an event tracking tool used to understand user behaviour within Automattic. This library provides a way for plugins to interact with the Tracks system and start recording events. + +### How to use + +Example: + +```php +use Automattic\VIP\Telemetry\Tracks; + +function track_post_status( $new_status, $old_status, $post ) { + $tracks = new Tracks( 'myplugin_' ); + + $tracks->record_event( 'post_status_changed', [ + 'new_status' => $new_status, + 'old_status' => $old_status, + 'post_id' => $post->ID, + ] ); +} +add_action( 'transition_post_status', 'track_post_status', 10, 3 ); +``` + +The example above is the most basic way to use this Tracks library. The client plugin would need a function to hook into the WordPress action they want to track and that function has to instantiate and call the `record_event` method from the `Tracks` class. This can be abstracted further to reduce code duplication by wrapping the functions in a class for example: + +```php +namespace MyPlugin\Telemetry; + +use Automattic\VIP\Telemetry\Tracks; + +class MyPluginTracker { + protected $tracks; + + public function __construct() { + $this->tracks = new Tracks( 'myplugin_' ); + } + + public function register_events() { + add_action( 'transition_post_status', [ $this, 'track_post_status' ], 10, 3 ); + } + + public function track_post_status( $new_status, $old_status, $post ) { + $this->tracks->record_event( 'post_status_changed', [ + 'new_status' => $new_status, + 'old_status' => $old_status, + 'post' => (array) $post, + ] ); + } +} +``` + +With the class above, you can then initiate event tracking in the main plugin file with these lines: + +```php +$tracker = new MyPluginTracker(); +$tracker->register_events(); +``` + +If necessary to provide global properties to all events, you can pass an array of properties to the `Tracks` constructor: + +```php +$this->tracks = new Tracks( 'myplugin_', [ + 'plugin_version' => '1.2.3', +] ); +``` \ No newline at end of file diff --git a/telemetry/class-telemetry-client.php b/telemetry/class-telemetry-client.php new file mode 100644 index 0000000000..51c9d9eacd --- /dev/null +++ b/telemetry/class-telemetry-client.php @@ -0,0 +1,26 @@ + + */ + protected array $events = []; + + /** + * Constructor. Registers the shutdown hook to record any and all events. + */ + public function __construct( Telemetry_Client $client ) { + $this->client = $client; + + // Register the shutdown hook to record any and all events + add_action( 'shutdown', array( $this, 'record_events' ) ); + } + + /** + * Enqueues an event to be recorded asynchronously. + * + * @param Telemetry_Event $event The event to record. + * @return bool|WP_Error True if the event was enqueued for recording. + * False if the event is not recordable. + * WP_Error if the event is generating an error. + */ + public function record_event_asynchronously( Telemetry_Event $event ): bool|WP_Error { + $is_event_recordable = $event->is_recordable(); + + if ( true !== $is_event_recordable ) { + return $is_event_recordable; + } + + $this->events[] = $event; + + return true; + } + + /** + * Records all queued events synchronously. + */ + public function record_events(): void { + if ( [] === $this->events ) { + return; + } + + // No back-off mechanism is implemented here, given the low cost of missing a few events. + // We also need to ensure that there's minimal disruption to a site's operations. + $this->client->batch_record_events( $this->events ); + $this->events = []; + } +} diff --git a/telemetry/class-telemetry-event.php b/telemetry/class-telemetry-event.php new file mode 100644 index 0000000000..108e28c5cf --- /dev/null +++ b/telemetry/class-telemetry-event.php @@ -0,0 +1,27 @@ + $event_properties Any additional properties + * to include with the event. + * @return bool|WP_Error True if recording the event succeeded. + * False if telemetry is disabled. + * WP_Error if recording the event failed. + */ + abstract public function record_event( + string $event_name, + array $event_properties = [] + ): bool|WP_Error; +} diff --git a/telemetry/class-tracks.php b/telemetry/class-tracks.php new file mode 100644 index 0000000000..46c16d75c4 --- /dev/null +++ b/telemetry/class-tracks.php @@ -0,0 +1,80 @@ + The global event properties to be included with every event. + */ + private array $global_event_properties = []; + + /** + * Tracks constructor. + * + * @param string $event_prefix The prefix for all event names. Defaults to 'vip_'. + * @param array $global_event_properties The global event properties to be included with every event. + * @param Telemetry_Event_Queue|null $queue The event queue to use. Falls back to the default queue when none provided. + * @param Tracks_Client|null $client The client instance to use. Falls back to the default client when none provided. + */ + public function __construct( string $event_prefix = 'vip_', array $global_event_properties = [], Telemetry_Event_Queue $queue = null, Tracks_Client $client = null ) { + $this->event_prefix = $event_prefix; + $this->global_event_properties = $global_event_properties; + $client ??= new Tracks_Client(); + $this->queue = $queue ?? new Telemetry_Event_Queue( $client ); + } + + /** + * Records an event to Tracks by using the Tracks API. + * + * If the event doesn't pass validation, it gets silently discarded. + * + * @param string $event_name The event name. Must be snake_case. + * @param array|array $event_properties Any additional properties to include with the event. + * Key names must be lowercase and snake_case. + * @return bool|WP_Error True if recording the event succeeded. + * False if telemetry is disabled. + * WP_Error if recording the event failed. + */ + public function record_event( + string $event_name, + array $event_properties = [] + ): bool|WP_Error { + if ( [] !== $this->global_event_properties ) { + $event_properties = array_merge( $this->global_event_properties, $event_properties ); + } + + $event = new Tracks_Event( $this->event_prefix, $event_name, $event_properties ); + + return $this->queue->record_event_asynchronously( $event ); + } +} diff --git a/telemetry/tracks/class-tracks-client.php b/telemetry/tracks/class-tracks-client.php new file mode 100644 index 0000000000..7774172721 --- /dev/null +++ b/telemetry/tracks/class-tracks-client.php @@ -0,0 +1,86 @@ +http = $http ?? _wp_http_get_object(); + } + + /** + * Record a batch of events using the Tracks REST API + * + * @param Tracks_Event[] $events Array of Tracks_Event objects to record + * @return bool|WP_Error True if batch recording succeeded. + * WP_Error is any error occured. + */ + public function batch_record_events( array $events, array $common_props = [] ): bool|WP_Error { + // filter out invalid events + $valid_events = array_filter( $events, function ( $event ) { + return $event instanceof Tracks_Event && $event->is_recordable() === true; + } ); + + // no events - nothing to do + if ( [] === $valid_events ) { + return true; + } + + $body = [ + 'events' => $valid_events, + 'commonProps' => $common_props, + ]; + + $response = $this->http->post( + static::TRACKS_ENDPOINT, + array( + 'body' => wp_json_encode( $body ), + 'user-agent' => 'viptelemetry', + 'headers' => array( + 'Content-Type' => 'application/json', + ), + ) + ); + + if ( is_wp_error( $response ) ) { + log2logstash( [ + 'severity' => 'error', + 'feature' => 'telemetry', + 'message' => 'error batch recording events to Tracks', + 'extra' => [ + 'error' => $response->get_error_messages(), + ], + ] ); + return $response; + } + + return true; + } +} diff --git a/telemetry/tracks/class-tracks-event-dto.php b/telemetry/tracks/class-tracks-event-dto.php new file mode 100644 index 0000000000..e8e56df211 --- /dev/null +++ b/telemetry/tracks/class-tracks-event-dto.php @@ -0,0 +1,45 @@ +|array $event_properties Any properties included in the event. + */ + public function __construct( string $prefix, string $event_name, array $event_properties = [] ) { + $this->prefix = $prefix; + $this->event_name = $event_name; + $this->event_properties = $event_properties; + $this->event_timestamp = microtime( true ); + } + + /** + * Returns the event's data. + * + * @return Tracks_Event_DTO|WP_Error Event object if the event was created successfully, WP_Error otherwise. + */ + public function get_data(): Tracks_Event_DTO|WP_Error { + if ( ! isset( $this->data ) ) { + $event_data = $this->process_properties( $this->prefix, $this->event_name, $this->event_properties ); + $validation_result = $this->get_event_validation_result( $event_data ); + + $this->data = $validation_result ?? $event_data; + } + + return $this->data; + } + + /** + * Returns the event's data for JSON representation. + */ + public function jsonSerialize(): mixed { + $data = $this->get_data(); + + if ( is_wp_error( $data ) ) { + return (object) []; + } + + return $data; + } + + /** + * Returns whether the event can be recorded. + * + * @return bool|WP_Error True if the event is recordable. + */ + public function is_recordable(): bool|WP_Error { + $data = $this->get_data(); + + if ( is_wp_error( $data ) ) { + return $data; + } + + return true; + } + + /** + * Processes the event's properties to get them ready for validation. + * + * @param string $event_prefix The event's prefix. + * @param string $event_name The event's name. + * @param array|array $event_properties Any event properties to be processed. + * @return Tracks_Event_DTO The resulting event object with processed properties. + */ + protected function process_properties( + string $event_prefix, + string $event_name, + array $event_properties + ): Tracks_Event_DTO { + $event = static::encode_properties( $event_properties ); + $event = static::set_user_properties( $event ); + + // Set event name. If the event name doesn't have the prefix, add it. + $event->_en = preg_replace( + '/^(?:' . $event_prefix . ')?(.+)/', + $event_prefix . '\1', + $event_name + ) ?? ''; + + // Set event timestamp. + if ( ! isset( $event->_ts ) ) { + $event->_ts = static::milliseconds_since_epoch( $this->event_timestamp ); + } + + // Remove non-routable IPs to prevent record from being discarded. + if ( isset( $event->_via_ip ) && + 1 === preg_match( '/^192\.168|^10\./', $event->_via_ip ) ) { + unset( $event->_via_ip ); + } + + // Set VIP environment if it exists. + if ( defined( 'VIP_GO_APP_ENVIRONMENT' ) ) { + $app_environment = constant( 'VIP_GO_APP_ENVIRONMENT' ); + if ( is_string( $app_environment ) && '' !== $app_environment ) { + $event->vipgo_env = $app_environment; + } + } + + // Set VIP organization if it exists. + if ( defined( 'VIP_ORG_ID' ) ) { + $org_id = constant( 'VIP_ORG_ID' ); + if ( is_string( $org_id ) && '' !== $org_id ) { + $event->vipgo_org = $org_id; + } + } + + // Check if the user is a VIP user. + $event->is_vip_user = Support_User::user_has_vip_support_role( get_current_user_id() ); + + return $event; + } + + /** + * Sets the Tracks User ID and User ID Type depending on the current + * environment. + * + * @param Tracks_Event_DTO $event The event to annotate with identity information. + * @return Tracks_Event_DTO The new event object including identity information. + */ + protected static function set_user_properties( Tracks_Event_DTO $event ): Tracks_Event_DTO { + $wp_user = wp_get_current_user(); + + // Only track logged-in users. + if ( 0 === $wp_user->ID ) { + return $event; + } + + // Set anonymized event user ID; it should be consistent across environments. + // VIP_TELEMETRY_SALT is a private constant shared across the platform. + if ( defined( 'VIP_TELEMETRY_SALT' ) ) { + $salt = constant( 'VIP_TELEMETRY_SALT' ); + $tracks_user_id = hash_hmac( 'sha256', $wp_user->user_email, $salt ); + + $event->_ui = $tracks_user_id; + $event->_ut = 'vip:user_email'; + + return $event; + } + + // Users in the VIP environment. + if ( defined( 'VIP_GO_APP_ID' ) ) { + $app_id = constant( 'VIP_GO_APP_ID' ); + if ( is_integer( $app_id ) && $app_id > 0 ) { + $event->_ui = sprintf( '%s_%s', $app_id, $wp_user->ID ); + $event->_ut = 'vip_go_app_wp_user'; + + return $event; + } + } + + // All other environments. + $event->_ui = wp_hash( sprintf( '%s|%s', get_option( 'home' ), $wp_user->ID ) ); + + /** + * @see \Automattic\VIP\Parsely\Telemetry\Tracks_Event::annotate_with_id_and_type() + */ + $event->_ut = 'anon'; // Same as the default value in the original code. + + return $event; + } + + /** + * Validates the event object. + * + * @param Tracks_Event_DTO $event Event object to validate. + * @return ?WP_Error null if validation passed, error otherwise. + */ + protected function get_event_validation_result( Tracks_Event_DTO $event ): ?WP_Error { + // Check that required fields are defined. + if ( ! $event->_en ) { + $msg = __( 'The _en property must be specified to non-empty value', 'vip-telemetry' ); + log2logstash( [ + 'severity' => 'error', + 'feature' => 'telemetry', + 'message' => $msg, + 'extra' => [ + 'event' => (array) $event, + ], + ] ); + return new WP_Error( + 'invalid_event', + $msg + ); + } + + // Validate Event Name (_en). + if ( ! static::event_name_is_valid( $event->_en ) ) { + $msg = __( 'A valid event name must be specified', 'vip-telemetry' ); + log2logstash( [ + 'severity' => 'error', + 'feature' => 'telemetry', + 'message' => $msg, + 'extra' => [ + 'event' => (array) $event, + ], + ] ); + return new WP_Error( + 'invalid_event_name', + $msg + ); + } + + + // Validate property names format. + foreach ( get_object_vars( $event ) as $key => $_ ) { + if ( ! static::property_name_is_valid( $key ) ) { + $msg = __( 'A valid property name must be specified', 'vip-telemetry' ); + log2logstash( [ + 'severity' => 'error', + 'feature' => 'telemetry', + 'message' => $msg, + 'extra' => [ + 'event' => (array) $event, + ], + ] ); + return new WP_Error( + 'invalid_property_name', + $msg + ); + } + } + + // Validate User ID (_ui) and User ID Type (_ut). + if ( ! isset( $event->_ui ) && ! isset( $event->_ut ) ) { + $msg = __( 'Could not determine user identity and type', 'vip-telemetry' ); + log2logstash( [ + 'severity' => 'error', + 'feature' => 'telemetry', + 'message' => $msg, + 'extra' => [ + 'event' => (array) $event, + ], + ] ); + return new WP_Error( + 'empty_user_information', + $msg + ); + } + + return null; + } + + /** + * Checks if the passed event name is valid. + * + * @param string $event_name The event's name. + * @return bool Whether the event name is valid. + */ + protected static function event_name_is_valid( string $event_name ): bool { + return 1 === preg_match( static::EVENT_NAME_REGEX, $event_name ); + } + + /** + * Checks if the passed property name is valid. + * + * @param string $property_name The property's name. + * @return bool Whether the property name is valid. + */ + protected static function property_name_is_valid( string $property_name ): bool { + return 1 === preg_match( static::PROPERTY_NAME_REGEX, $property_name ); + } + + /** + * Sanitizes the passed properties array, JSON-encoding non-string values. + * + * @param array|array $event_properties The array to be sanitized. + * @return Tracks_Event_DTO The sanitized object. + */ + protected static function encode_properties( array $event_properties ): Tracks_Event_DTO { + $result = new Tracks_Event_DTO(); + + foreach ( $event_properties as $key => $value ) { + if ( is_string( $value ) ) { + $result->$key = $value; + continue; + } + + $result->$key = wp_json_encode( $value ); + } + + return $result; + } + + /** + * Builds a JS compatible timestamp for the event (integer number of milliseconds since the Unix Epoch). + * + * @return string + */ + protected static function milliseconds_since_epoch( float $microtime ): string { + $timestamp = round( $microtime * 1000 ); + + return number_format( $timestamp, 0, '', '' ); + } +} diff --git a/tests/mock-constants.php b/tests/mock-constants.php index 7bf061d438..2e4da4526d 100644 --- a/tests/mock-constants.php +++ b/tests/mock-constants.php @@ -248,3 +248,19 @@ function constant( $constant ) { return Constant_Mocker::constant( $constant ); } } + +namespace Automattic\VIP\Telemetry\Tracks { + use Automattic\Test\Constant_Mocker; + + function define( $constant, $value ) { + Constant_Mocker::define( $constant, $value ); + } + + function defined( $constant ) { + return Constant_Mocker::defined( $constant ); + } + + function constant( $constant ) { + return Constant_Mocker::constant( $constant ); + } +} diff --git a/tests/parsely/test-mu-parsely-integration.php b/tests/parsely/test-mu-parsely-integration.php index 4576b9bc1f..a155065e9b 100644 --- a/tests/parsely/test-mu-parsely-integration.php +++ b/tests/parsely/test-mu-parsely-integration.php @@ -139,24 +139,6 @@ public function test_bootstrap_modes_enabled_without_constant() { ); $this->assertEquals( Parsely_Integration_Type::DISABLED_MUPLUGINS_FILTER, Parsely_Loader_Info::get_integration_type() ); break; - case 'option_enabled': - $this->assertFalse( has_filter( 'wpvip_parsely_load_mu' ) ); - $this->assertSame( '1', get_option( '_wpvip_parsely_mu' ) ); - $this->assertTrue( - Parsely_Loader_Info::is_active(), - 'Expecting wp-parsely plugin to be enabled by the option.' - ); - $this->assertEquals( Parsely_Integration_Type::ENABLED_MUPLUGINS_SILENT_OPTION, Parsely_Loader_Info::get_integration_type() ); - break; - case 'option_disabled': - $this->assertFalse( has_filter( 'wpvip_parsely_load_mu' ) ); - $this->assertSame( '0', get_option( '_wpvip_parsely_mu' ) ); - $this->assertFalse( - Parsely_Loader_Info::is_active(), - 'Expecting wp-parsely plugin to be disabled by the option.' - ); - $this->assertEquals( Parsely_Integration_Type::DISABLED_MUPLUGINS_SILENT_OPTION, Parsely_Loader_Info::get_integration_type() ); - break; case 'filter_and_option_enabled': $this->assertTrue( has_filter( 'wpvip_parsely_load_mu' ) ); $this->assertSame( '1', get_option( '_wpvip_parsely_mu' ) ); @@ -238,22 +220,6 @@ public function test_bootstrap_modes_disabled_via_constant() { $this->assertTrue( has_filter( 'wpvip_parsely_load_mu' ) ); $this->assertFalse( get_option( '_wpvip_parsely_mu' ) ); break; - case 'option_enabled': - $this->assertFalse( has_filter( 'wpvip_parsely_load_mu' ) ); - $this->assertSame( '1', get_option( '_wpvip_parsely_mu' ) ); - break; - case 'option_disabled': - $this->assertFalse( has_filter( 'wpvip_parsely_load_mu' ) ); - $this->assertSame( '0', get_option( '_wpvip_parsely_mu' ) ); - break; - case 'filter_and_option_enabled': - $this->assertTrue( has_filter( 'wpvip_parsely_load_mu' ) ); - $this->assertSame( '1', get_option( '_wpvip_parsely_mu' ) ); - break; - case 'filter_and_option_disabled': - $this->assertTrue( has_filter( 'wpvip_parsely_load_mu' ) ); - $this->assertSame( '0', get_option( '_wpvip_parsely_mu' ) ); - break; default: $this->fail( 'Invalid test mode specified: ' . self::$test_mode ); } @@ -262,29 +228,6 @@ public function test_bootstrap_modes_disabled_via_constant() { $this->assertEquals( Parsely_Integration_Type::DISABLED_CONSTANT, Parsely_Loader_Info::get_integration_type() ); } - public function test_parsely_ui_hooks() { - maybe_load_plugin(); - - $this->assertFalse( has_action( 'option_parsely', __NAMESPACE__ . '\alter_option_use_repeated_metas' ) ); - - if ( is_parsely_disabled() ) { - return; - } - - \Parsely\parsely_initialize_plugin(); - maybe_disable_some_features(); - - $repeated_metas_expected = 'option_enabled' === self::$test_mode ? 10 : false; - $this->assertSame( $repeated_metas_expected, has_action( 'option_parsely', __NAMESPACE__ . '\alter_option_use_repeated_metas' ) ); - - $row_actions = new Row_Actions( $GLOBALS['parsely'] ); - $row_actions->run(); - - $row_actions_expected = in_array( self::$test_mode, [ 'filter_enabled', 'filter_and_option_enabled' ] ) ? 10 : false; - $this->assertSame( $row_actions_expected, has_filter( 'page_row_actions', array( $row_actions, 'row_actions_add_parsely_link' ) ) ); - $this->assertSame( $row_actions_expected, has_filter( 'post_row_actions', array( $row_actions, 'row_actions_add_parsely_link' ) ) ); - } - public function test_default_parsely_configs() { maybe_load_plugin(); diff --git a/tests/telemetry/test-class-tracks-event-queue.php b/tests/telemetry/test-class-tracks-event-queue.php new file mode 100644 index 0000000000..cc48192c3f --- /dev/null +++ b/tests/telemetry/test-class-tracks-event-queue.php @@ -0,0 +1,39 @@ +getMockBuilder( Telemetry_Client::class ) + ->disableOriginalConstructor() + ->getMock(); + + $event = $this->getMockBuilder( Telemetry_Event::class ) + ->disableOriginalConstructor() + ->getMock(); + + $event->expects( $this->once() )->method( 'is_recordable' )->willReturn( true ); + + $bad_event = $this->getMockBuilder( Telemetry_Event::class ) + ->disableOriginalConstructor() + ->getMock(); + + $bad_event->expects( $this->once() )->method( 'is_recordable' )->willReturn( false ); + + $client->expects( $this->once() ) + ->method( 'batch_record_events' ) + ->with( [ $event ] ) + ->willReturn( true ); + + $queue = new Telemetry_Event_Queue( $client ); + $queue->record_event_asynchronously( $event ); + $queue->record_event_asynchronously( $bad_event ); + $queue->record_events(); + $queue->record_events(); + } +} diff --git a/tests/telemetry/test-class-tracks.php b/tests/telemetry/test-class-tracks.php new file mode 100644 index 0000000000..8fe36475da --- /dev/null +++ b/tests/telemetry/test-class-tracks.php @@ -0,0 +1,81 @@ +factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->once() ) + ->method( 'record_event_asynchronously' ) + ->with($this->callback(function ( Tracks_Event $event ) { + $this->assertSame( 'test_cool_event', $event->get_data()->_en ); + $this->assertSame( 'bar', $event->get_data()->foo ); + $this->assertFalse( isset( $event->get_data()->global_baz ) ); + + return true; + })) + ->willReturn( true ); + + $tracks = new Tracks( 'test_', [], $queue ); + $this->assertTrue( $tracks->record_event( 'cool_event', [ 'foo' => 'bar' ] ) ); + } + + public function test_event_queued_with_global_properies() { + $user = $this->factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->once() ) + ->method( 'record_event_asynchronously' ) + ->with($this->callback(function ( Tracks_Event $event ) { + $this->assertSame( 'nice_fuzzy_event', $event->get_data()->_en ); + $this->assertSame( 'bar', $event->get_data()->foo ); + $this->assertSame( 'qux', $event->get_data()->global_baz ); + + return true; + })) + ->willReturn( true ); + + $tracks = new Tracks( 'nice_', [ + 'global_baz' => 'qux', + 'foo' => 'default_foo', + ], $queue ); + $this->assertTrue( $tracks->record_event( 'fuzzy_event', [ 'foo' => 'bar' ] ) ); + } + + public function test_event_prefix() { + $tracks = new Tracks(); + $event_prefix = self::get_property( 'event_prefix' )->getValue( $tracks ); + $this->assertEquals( 'vip_', $event_prefix ); + } + + public function test_custom_event_prefix() { + $tracks = new Tracks( 'test_' ); + $event_prefix = self::get_property( 'event_prefix' )->getValue( $tracks ); + $this->assertEquals( 'test_', $event_prefix ); + } + + /** + * Helper function for accessing protected properties. + */ + protected static function get_property( $name ) { + $class = new \ReflectionClass( Tracks::class ); + $property = $class->getProperty( $name ); + $property->setAccessible( true ); + return $property; + } +} diff --git a/tests/telemetry/tracks/test-class-tracks-client.php b/tests/telemetry/tracks/test-class-tracks-client.php new file mode 100644 index 0000000000..8413874012 --- /dev/null +++ b/tests/telemetry/tracks/test-class-tracks-client.php @@ -0,0 +1,90 @@ +getMockBuilder( WP_Http::class ) + ->disableOriginalConstructor() + ->getMock(); + + $event = $this->getMockBuilder( Tracks_Event::class ) + ->disableOriginalConstructor() + ->getMock(); + + $event->expects( $this->once() )->method( 'is_recordable' )->willReturn( true ); + $event->expects( $this->once() )->method( 'jsonSerialize' )->willReturn( [ 'test_event' => true ] ); + + $bad_event = $this->getMockBuilder( Tracks_Event::class ) + ->disableOriginalConstructor() + ->getMock(); + + $bad_event->expects( $this->once() )->method( 'is_recordable' )->willReturn( false ); + + $http->expects( $this->once() ) + ->method( 'post' ) + ->with( $this->stringContains( 'tracks/record' ), [ + 'body' => wp_json_encode([ + 'events' => [ [ 'test_event' => true ] ], + 'commonProps' => [ 'foo' => 'bar' ], + ]), + 'user-agent' => 'viptelemetry', + 'headers' => array( + 'Content-Type' => 'application/json', + ), + + ] ) + ->willReturn( true ); + + $client = new Tracks_Client( $http ); + $this->assertTrue( $client->batch_record_events( [ $event, $bad_event ], [ 'foo' => 'bar' ] ) ); + } + + public function test_should_handle_failed_requests() { + $http = $this->getMockBuilder( WP_Http::class ) + ->disableOriginalConstructor() + ->getMock(); + + $event = $this->getMockBuilder( Tracks_Event::class ) + ->disableOriginalConstructor() + ->getMock(); + + $event->expects( $this->once() )->method( 'is_recordable' )->willReturn( true ); + $event->expects( $this->once() )->method( 'jsonSerialize' )->willReturn( [ 'test_event' => true ] ); + + $error = new WP_Error( 'http_request_failed', 'This is a failure' ); + + $http->expects( $this->once() ) + ->method( 'post' ) + ->with( $this->stringContains( 'tracks/record' ) ) + ->willReturn( $error ); + + $client = new Tracks_Client( $http ); + $this->assertSame( $error, $client->batch_record_events( [ $event ], [ 'foo' => 'bar' ] ) ); + } + + public function test_should_not_make_requests_for_no_events() { + $http = $this->getMockBuilder( WP_Http::class ) + ->disableOriginalConstructor() + ->getMock(); + + $bad_event = $this->getMockBuilder( Tracks_Event::class ) + ->disableOriginalConstructor() + ->getMock(); + + $bad_event->expects( $this->once() )->method( 'is_recordable' )->willReturn( false ); + + $http->expects( $this->never() ) + ->method( 'post' ); + + $client = new Tracks_Client( $http ); + $this->assertTrue( $client->batch_record_events( [ $bad_event ], [ 'foo' => 'bar' ] ) ); + } +} diff --git a/tests/telemetry/tracks/test-class-tracks-event-dto.php b/tests/telemetry/tracks/test-class-tracks-event-dto.php new file mode 100644 index 0000000000..27e7aeb421 --- /dev/null +++ b/tests/telemetry/tracks/test-class-tracks-event-dto.php @@ -0,0 +1,16 @@ +assertInstanceOf( Tracks_Event_DTO::class, $event ); + } +} diff --git a/tests/telemetry/tracks/test-class-tracks-event.php b/tests/telemetry/tracks/test-class-tracks-event.php new file mode 100644 index 0000000000..d957b77bc2 --- /dev/null +++ b/tests/telemetry/tracks/test-class-tracks-event.php @@ -0,0 +1,192 @@ +user = $this->factory()->user->create_and_get(); + wp_set_current_user( $this->user->ID ); + + parent::setUp(); + } + + public function tearDown(): void { + Constant_Mocker::clear(); + parent::tearDown(); + } + + public function test_should_create_event() { + $event = new Tracks_Event( 'prefix_', 'test_event', [ 'property1' => 'value1' ] ); + + $this->assertInstanceOf( Tracks_Event::class, $event ); + } + + public function test_should_return_event_data() { + Constant_Mocker::define( 'VIP_TELEMETRY_SALT', self::VIP_TELEMETRY_SALT ); + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', self::VIP_GO_APP_ENVIRONMENT ); + Constant_Mocker::define( 'VIP_ORG_ID', self::VIP_ORG_ID ); + + $event = new Tracks_Event( 'prefix_', 'test_event', [ + 'property1' => 'value1', + '_via_ip' => '1.2.3.4', + ] ); + + if ( $event->get_data() instanceof WP_Error ) { + $this->fail( sprintf( '%s: %s', $event->get_data()->get_error_code(), $event->get_data()->get_error_message() ) ); + } + + $this->assertInstanceOf( Tracks_Event_DTO::class, $event->get_data() ); + $this->assertIsString( $event->get_data()->_ts ); + $this->assertGreaterThan( ( time() - 10 ) * 1000, (int) $event->get_data()->_ts ); + $this->assertSame( 'prefix_test_event', $event->get_data()->_en ); + $this->assertSame( 'value1', $event->get_data()->property1 ); + $this->assertSame( '1.2.3.4', $event->get_data()->_via_ip ); + $this->assertSame( hash_hmac( 'sha256', $this->user->user_email, self::VIP_TELEMETRY_SALT ), $event->get_data()->_ui ); + $this->assertSame( 'vip:user_email', $event->get_data()->_ut ); + $this->assertSame( self::VIP_GO_APP_ENVIRONMENT, $event->get_data()->vipgo_env ); + $this->assertSame( self::VIP_ORG_ID, $event->get_data()->vipgo_org ); + $this->assertFalse( $event->get_data()->is_vip_user ); + $this->assertTrue( $event->is_recordable() ); + } + + public function test_should_not_add_prefix_twice() { + $event = new Tracks_Event( 'prefixed_', 'prefixed_event_name' ); + + $this->assertNotInstanceOf( WP_Error::class, $event->get_data() ); + + $this->assertSame( 'prefixed_event_name', $event->get_data()->_en ); + } + + public function test_should_not_override_timestamp() { + $ts = 1234567890; + $event = new Tracks_Event( 'prefixed_', 'example', [ + '_ts' => $ts, + ] ); + + $this->assertSame( (string) $ts, $event->get_data()->_ts ); + } + + public function test_should_encode_complex_properties() { + $event = new Tracks_Event( 'prefix_', 'event_name', [ 'example' => [ 'a' => 'b' ] ] ); + + $this->assertNotInstanceOf( WP_Error::class, $event->get_data() ); + + $this->assertSame( '{"a":"b"}', $event->get_data()->example ); + } + + public function test_should_not_encode_errors_to_json() { + $event = new Tracks_Event( 'prefix_', 'bogus name' ); + + $this->assertInstanceOf( WP_Error::class, $event->get_data() ); + + $this->assertSame( '{}', wp_json_encode( $event ) ); + } + + public function test_should_fallback_to_vip_go_app_wp_user() { + Constant_Mocker::define( 'VIP_GO_APP_ID', 1234 ); + + $event = new Tracks_Event( 'prefix_', 'test_event' ); + + $this->assertNotInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertSame( 'vip_go_app_wp_user', $event->get_data()->_ut ); + $this->assertSame( '1234_' . $this->user->ID, $event->get_data()->_ui ); + } + + public function test_should_fallback_to_anon_wp_hash() { + $event = new Tracks_Event( 'prefix_', 'test_event' ); + + $this->assertNotInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertSame( 'anon', $event->get_data()->_ut ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $event->get_data()->_ui ); + } + + public function test_should_not_record_events_for_logged_out_users() { + wp_set_current_user( 0 ); + + $event = new Tracks_Event( 'prefix_', 'test_event' ); + + $this->assertInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertSame( 'empty_user_information', $event->get_data()->get_error_code() ); + } + + public static function provide_non_routable_ips() { + yield [ '192.168.10.1' ]; + yield [ '10.11.10.11' ]; + } + + /** + * @dataProvider provide_non_routable_ips + */ + public function test_should_remove_non_routable_ips( string $_via_ip ) { + $event = new Tracks_Event( 'prefix_', 'example', [ '_via_ip' => $_via_ip ] ); + + $this->assertNotInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertFalse( isset( $event->get_data()->_via_ip ) ); + $this->assertStringNotContainsString( 'via_ip', wp_json_encode( $event ) ); + } + + public function test_should_return_error_on_missing_event_name() { + $event = new Tracks_Event( 'prefix_', '', [ 'property1' => 'value1' ] ); + + $this->assertInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertInstanceOf( WP_Error::class, $event->is_recordable() ); + $this->assertSame( $event->is_recordable(), $event->get_data() ); + + $this->assertSame( 'invalid_event', $event->get_data()->get_error_code() ); + } + + public static function provide_invalid_event_names() { + yield 'spaces' => [ 'cool page viewed' ]; + yield 'dashes' => [ 'cool-page-viewed' ]; + yield 'mixed-case' => [ 'cool_page_Viewed' ]; + } + + /** + * @dataProvider provide_invalid_event_names + */ + public function test_should_return_error_on_invalid_event_name( string $event_name ) { + $event = new Tracks_Event( 'prefix_', $event_name, [ 'property1' => 'value1' ] ); + + $this->assertInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertInstanceOf( WP_Error::class, $event->is_recordable() ); + $this->assertSame( $event->is_recordable(), $event->get_data() ); + + $this->assertSame( 'invalid_event_name', $event->get_data()->get_error_code() ); + } + + public static function provide_invalid_property_names() { + yield 'empty' => [ '' ]; + yield 'spaces' => [ 'cool property' ]; + yield 'mixed-case' => [ 'cool_Property' ]; + yield 'camelCase' => [ 'compressedSize' ]; + yield 'dashes' => [ 'cool-property' ]; + } + + /** + * @dataProvider provide_invalid_property_names + */ + public function test_should_return_error_on_invalid_property_name( string $property_name ) { + $event = new Tracks_Event( 'prefix_', 'test_event', [ $property_name => 'value1' ] ); + + $this->assertInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertInstanceOf( WP_Error::class, $event->is_recordable() ); + $this->assertSame( $event->is_recordable(), $event->get_data() ); + $this->assertSame( 'invalid_property_name', $event->get_data()->get_error_code() ); + } +} diff --git a/wp-parsely.php b/wp-parsely.php index 75f1d69ef3..2a2beb28a8 100644 --- a/wp-parsely.php +++ b/wp-parsely.php @@ -17,6 +17,8 @@ namespace Automattic\VIP\WP_Parsely_Integration; +use Parsely\Parsely; + /** * The default version is the first entry in the SUPPORTED_VERSIONS list. */ @@ -174,14 +176,14 @@ public static function get_parsely_options(): array { } /** - * Parse.ly options. + * Parse.ly options, plugin may be not loaded at this moment in the runtime, but we want to check the options anyway. * * @var array */ - $parsely_options = array(); - if ( isset( $GLOBALS['parsely'] ) && is_a( $GLOBALS['parsely'], 'Parsely\Parsely' ) ) { $parsely_options = $GLOBALS['parsely']->get_options(); + } else { + $parsely_options = get_option( 'parsely', [] ); } return $parsely_options; @@ -240,7 +242,6 @@ function is_queued_for_activation() { * To enable it on your site, add this line: * add_filter( 'wpvip_parsely_load_mu', '__return_true' ); * - * We enable it for some sites via the `_wpvip_parsely_mu` blog option. * To prevent it from loading even when this condition is met, add this line: * add_filter( 'wpvip_parsely_load_mu', '__return_false' ); */ @@ -250,61 +251,46 @@ function maybe_load_plugin() { return; } - // Self-managed integration: The plugin exists on the site and is being loaded already. - $plugin_class_exists = class_exists( 'Parsely' ) || class_exists( 'Parsely\Parsely' ); - if ( $plugin_class_exists ) { - Parsely_Loader_Info::set_active( true ); - Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::SELF_MANAGED ); - - $parsely_options = Parsely_Loader_Info::get_parsely_options(); - if ( array_key_exists( 'plugin_version', $parsely_options ) ) { - Parsely_Loader_Info::set_version( $parsely_options['plugin_version'] ); - } - - return; - } - - $parsely_enabled_constant = null; // Represents that the site doesn't have parsely enabled / blocked. - - if ( defined( 'VIP_PARSELY_ENABLED' ) ) { - $parsely_enabled_constant = constant( 'VIP_PARSELY_ENABLED' ); - - // Opt out if constant value isn't true. - if ( true !== $parsely_enabled_constant ) { - Parsely_Loader_Info::set_active( false ); - Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::DISABLED_CONSTANT ); - - return; - } - - Parsely_Loader_Info::set_active( true ); - Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::ENABLED_CONSTANT ); - } - - $option_load_status = get_option( '_wpvip_parsely_mu', null ); $filtered_load_status = apply_filters( 'wpvip_parsely_load_mu', null ); - // If plugin isn't enabled via constant then check for filter and option status. - if ( true !== $parsely_enabled_constant ) { - $should_load = true === $filtered_load_status || '1' === $option_load_status; - $should_prevent_loading = false === $filtered_load_status || '0' === $option_load_status; - - // No integration: The site has not enabled parsely. - if ( ! $should_load || $should_prevent_loading ) { - Parsely_Loader_Info::set_active( false ); - - if ( false === $filtered_load_status ) { - Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::DISABLED_MUPLUGINS_FILTER ); - } elseif ( '0' === $option_load_status ) { - Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::DISABLED_MUPLUGINS_SILENT_OPTION ); + switch ( true ) { + // Self-managed + case class_exists( 'Parsely' ) || class_exists( 'Parsely\Parsely' ): + Parsely_Loader_Info::set_active( true ); + Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::SELF_MANAGED ); + $parsely_options = Parsely_Loader_Info::get_parsely_options(); + if ( array_key_exists( 'plugin_version', $parsely_options ) ) { + Parsely_Loader_Info::set_version( $parsely_options['plugin_version'] ); } + break; + // Integrations-managed + case defined( 'VIP_PARSELY_ENABLED' ): + Parsely_Loader_Info::set_active( true === constant( 'VIP_PARSELY_ENABLED' ) ); + Parsely_Loader_Info::set_integration_type( + Parsely_Loader_Info::is_active() + ? Parsely_Integration_Type::ENABLED_CONSTANT + : Parsely_Integration_Type::DISABLED_CONSTANT + ); + break; + // Filter-managed - enabled + case $filtered_load_status: + Parsely_Loader_Info::set_active( true ); + Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::ENABLED_MUPLUGINS_FILTER ); + break; + // Filter-managed - disabled + case false === $filtered_load_status: + Parsely_Loader_Info::set_active( false ); + Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::DISABLED_MUPLUGINS_FILTER ); + break; + // Not configured in any way + default: + Parsely_Loader_Info::set_active( false ); + Parsely_Loader_Info::set_integration_type( Parsely_Integration_Type::NONE ); + break; + } - return; - } - - // Enqueuing the disabling of Parse.ly features when the plugin is loaded (after the `plugins_loaded` hook) - // We need priority 0, so it's executed before `widgets_init`. - add_action( 'init', __NAMESPACE__ . '\maybe_disable_some_features', 0 ); + if ( ! Parsely_Loader_Info::is_active() || Parsely_Integration_Type::SELF_MANAGED === Parsely_Loader_Info::get_integration_type() ) { + return; } $versions_to_try = SUPPORTED_VERSIONS; @@ -344,23 +330,16 @@ function maybe_load_plugin() { // Require the actual wp-parsely plugin. if ( ! is_readable( $entry_file ) ) { + Parsely_Loader_Info::set_active( false ); return; } - require_once $entry_file; - // If plugin isn't enabled via constant then set filter or option integration_type. - if ( true !== $parsely_enabled_constant ) { - $integration_type = Parsely_Integration_Type::ENABLED_MUPLUGINS_FILTER; - if ( '1' === $option_load_status && true !== $filtered_load_status ) { - $integration_type = Parsely_Integration_Type::ENABLED_MUPLUGINS_SILENT_OPTION; - } + require_once $entry_file; - Parsely_Loader_Info::set_integration_type( $integration_type ); + if ( defined( '\Parsely\PARSELY_VERSION' ) ) { + Parsely_Loader_Info::set_version( constant( '\Parsely\PARSELY_VERSION' ) ); } - Parsely_Loader_Info::set_active( true ); - Parsely_Loader_Info::set_version( $version ); - // Require VIP's customizations over wp-parsely. $vip_parsely_plugin = __DIR__ . '/vip-parsely/vip-parsely.php'; if ( is_readable( $vip_parsely_plugin ) ) { @@ -369,54 +348,17 @@ function maybe_load_plugin() { } add_action( 'plugins_loaded', __NAMESPACE__ . '\maybe_load_plugin', 1 ); -/** - * Hides the UI if the plugin is loaded via silent option. - */ -function maybe_disable_some_features() { - if ( ! isset( $GLOBALS['parsely'] ) || ! is_a( $GLOBALS['parsely'], 'Parsely\Parsely' ) ) { - return; - } - - $filtered_load_status = apply_filters( 'wpvip_parsely_load_mu', null ); - $should_disable_features = apply_filters( 'wpvip_parsely_hide_ui_for_mu', true !== $filtered_load_status ); - - // If the plugin was not loaded via the filter, hide the UI by default. - if ( $should_disable_features ) { - remove_action( 'init', 'Parsely\parsely_wp_admin_early_register' ); - remove_action( 'init', 'Parsely\init_recommendations_block' ); - remove_action( 'enqueue_block_editor_assets', 'Parsely\init_content_helper' ); - remove_action( 'admin_init', 'Parsely\parsely_admin_init_register' ); - remove_action( 'widgets_init', 'Parsely\parsely_recommended_widget_register' ); - - // Don't show the row action links. - add_filter( 'wp_parsely_enable_row_action_links', '__return_false' ); - add_filter( 'wp_parsely_enable_rest_api_support', '__return_false' ); - add_filter( 'wp_parsely_enable_related_api_proxy', '__return_false' ); - - // Default to "repeated metas". - add_filter( 'option_parsely', __NAMESPACE__ . '\alter_option_use_repeated_metas' ); - - // Remove the Parse.ly Recommended Widget. - unregister_widget( 'Parsely_Recommended_Widget' ); - } -} - /** * Enum which represent all options to integrate `wp-parsely`. */ abstract class Parsely_Integration_Type { // phpcs:ignore Generic.Files.OneObjectStructurePerFile.MultipleFound, Generic.Classes.OpeningBraceSameLine.ContentAfterBrace - // When parsely is active. - const ENABLED_MUPLUGINS_FILTER = 'ENABLED_MUPLUGINS_FILTER'; - const ENABLED_MUPLUGINS_SILENT_OPTION = 'ENABLED_MUPLUGINS_SILENT_OPTION'; - const ENABLED_CONSTANT = 'ENABLED_CONSTANT'; - - const SELF_MANAGED = 'SELF_MANAGED'; - - // When parsely is not active. - const DISABLED_MUPLUGINS_FILTER = 'DISABLED_MUPLUGINS_FILTER'; - const DISABLED_MUPLUGINS_SILENT_OPTION = 'DISABLED_MUPLUGINS_SILENT_OPTION'; - const DISABLED_CONSTANT = 'DISABLED_CONSTANT'; // Prevent loading of plugin based on integration meta attribute or customers can also define it. - + // When Parse.ly is active. + const ENABLED_MUPLUGINS_FILTER = 'ENABLED_MUPLUGINS_FILTER'; + const ENABLED_CONSTANT = 'ENABLED_CONSTANT'; + const SELF_MANAGED = 'SELF_MANAGED'; + // When Parse.ly is not active. + const DISABLED_MUPLUGINS_FILTER = 'DISABLED_MUPLUGINS_FILTER'; + const DISABLED_CONSTANT = 'DISABLED_CONSTANT'; // Prevent loading of plugin based on integration meta attribute or customers can also define it. + // When Parse.ly is not configured in any way. const NONE = 'NONE'; - const NULL = 'NULL'; }