From dd01619f525240093dc9a0fe996869ac75b6b177 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 19 Feb 2025 18:37:35 -0500 Subject: [PATCH] Implement Pendo Track events --- 000-vip-init.php | 8 +- telemetry/README.md | 50 ++--- telemetry/class-telemetry-event.php | 51 ++++- telemetry/class-telemetry.php | 75 +++++++ telemetry/pendo/class-pendo-track-client.php | 104 +++++++++ .../pendo/class-pendo-track-event-dto.php | 38 ++++ telemetry/pendo/class-pendo-track-event.php | 166 ++++++++++++++ telemetry/pendo/class-pendo.php | 183 ++++++++++++++++ telemetry/pendo/pendo-utils.php | 77 +++++++ telemetry/tracks/class-tracks-event.php | 48 +---- telemetry/{ => tracks}/class-tracks.php | 0 tests/mock-constants.php | 32 +++ .../pendo/test-class-pendo-client.php | 92 ++++++++ .../pendo/test-class-pendo-track-event.php | 192 +++++++++++++++++ tests/telemetry/pendo/test-class-pendo.php | 202 ++++++++++++++++++ tests/telemetry/pendo/test-pendo-utils.php | 53 +++++ ...p => test-class-telemetry-event-queue.php} | 0 tests/telemetry/test-class-telemetry.php | 107 ++++++++++ .../{ => tracks}/test-class-tracks.php | 0 .../{ => tracks}/test-tracks-utils.php | 0 20 files changed, 1407 insertions(+), 71 deletions(-) create mode 100644 telemetry/class-telemetry.php create mode 100644 telemetry/pendo/class-pendo-track-client.php create mode 100644 telemetry/pendo/class-pendo-track-event-dto.php create mode 100644 telemetry/pendo/class-pendo-track-event.php create mode 100644 telemetry/pendo/class-pendo.php create mode 100644 telemetry/pendo/pendo-utils.php rename telemetry/{ => tracks}/class-tracks.php (100%) create mode 100644 tests/telemetry/pendo/test-class-pendo-client.php create mode 100644 tests/telemetry/pendo/test-class-pendo-track-event.php create mode 100644 tests/telemetry/pendo/test-class-pendo.php create mode 100644 tests/telemetry/pendo/test-pendo-utils.php rename tests/telemetry/{test-class-tracks-event-queue.php => test-class-telemetry-event-queue.php} (100%) create mode 100644 tests/telemetry/test-class-telemetry.php rename tests/telemetry/{ => tracks}/test-class-tracks.php (100%) rename tests/telemetry/{ => tracks}/test-tracks-utils.php (100%) diff --git a/000-vip-init.php b/000-vip-init.php index c5992ac6a6..9bf01726aa 100644 --- a/000-vip-init.php +++ b/000-vip-init.php @@ -235,14 +235,20 @@ // Load the Telemetry files require_once __DIR__ . '/telemetry/class-telemetry-system.php'; -require_once __DIR__ . '/telemetry/class-tracks.php'; require_once __DIR__ . '/telemetry/class-telemetry-client.php'; require_once __DIR__ . '/telemetry/class-telemetry-event-queue.php'; require_once __DIR__ . '/telemetry/class-telemetry-event.php'; +require_once __DIR__ . '/telemetry/class-telemetry.php'; +require_once __DIR__ . '/telemetry/tracks/class-tracks.php'; require_once __DIR__ . '/telemetry/tracks/class-tracks-event-dto.php'; require_once __DIR__ . '/telemetry/tracks/class-tracks-event.php'; require_once __DIR__ . '/telemetry/tracks/class-tracks-client.php'; require_once __DIR__ . '/telemetry/tracks/tracks-utils.php'; +require_once __DIR__ . '/telemetry/pendo/class-pendo.php'; +require_once __DIR__ . '/telemetry/pendo/class-pendo-track-client.php'; +require_once __DIR__ . '/telemetry/pendo/class-pendo-track-event-dto.php'; +require_once __DIR__ . '/telemetry/pendo/class-pendo-track-event.php'; +require_once __DIR__ . '/telemetry/pendo/pendo-utils.php'; add_action( 'init', [ WPComVIP_Restrictions::class, 'instance' ] ); diff --git a/telemetry/README.md b/telemetry/README.md index 9156160325..1e1461d3bf 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -1,20 +1,23 @@ # VIP Telemetry Library -## Tracks +For most use cases, you should use the `Telemetry` class, which will send events to all configured telemetry systems: -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. +- Tracks is an event tracking tool used to understand user behavior within Automattic. +- Pendo is a product analytics tool used by WPVIP to understand user behavior on WordPress admin screens. Note that Pendo is completely disabled in some environments; see `Pendo::is_pendo_enabled_for_environment()` for details. -### How to use +If you would like to configure and use the telemetry systems individually, see the section below. -Example: +## How to use + +In this simple functional example, we track changes to the status of posts. Note that `myplugin_` is passed as the first argument to the `Telemetry` constructor; it will be prepended to all event names. ```php -use Automattic\VIP\Telemetry\Tracks; +use Automattic\VIP\Telemetry\Telemetry; function track_post_status( $new_status, $old_status, $post ) { - $tracks = new Tracks( 'myplugin_' ); + $telemetry = new Telemetry( 'myplugin_' ); - $tracks->record_event( 'post_status_changed', [ + $telemetry->record_event( 'post_status_changed', [ 'new_status' => $new_status, 'old_status' => $old_status, 'post_id' => $post->ID, @@ -23,26 +26,22 @@ function track_post_status( $new_status, $old_status, $post ) { 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: +To reduce code duplication, you may wish to create a class to encapsulate tracking logic: ```php -namespace MyPlugin\Telemetry; - -use Automattic\VIP\Telemetry\Tracks; - class MyPluginTracker { - protected $tracks; + protected Telemetry $telemetry; public function __construct() { - $this->tracks = new Tracks( 'myplugin_' ); + $this->telemetry = new Telemetry( 'myplugin_' ); } - public function register_events() { + public function init() { 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', [ + $this->telemetry->record_event( 'post_status_changed', [ 'new_status' => $new_status, 'old_status' => $old_status, 'post' => (array) $post, @@ -51,17 +50,20 @@ class MyPluginTracker { } ``` -With the class above, you can then initiate event tracking in the main plugin file with these lines: +If you would like to provide global properties to all events, you can pass an array of properties to the `Telemetry` constructor: ```php -$tracker = new MyPluginTracker(); -$tracker->register_events(); +new Telemetry( 'myplugin_', [ 'plugin_version' => '1.2.3' ] ); ``` -If necessary to provide global properties to all events, you can pass an array of properties to the `Tracks` constructor: +## Using Tracks and Pendo individually + +If you wish, you can configure and use `Tracks` and `Pendo` classes individually. They have the same API as the `Telemetry` class. ```php -$this->tracks = new Tracks( 'myplugin_', [ - 'plugin_version' => '1.2.3', -] ); -``` \ No newline at end of file +use Automattic\VIP\Telemetry\Pendo; +use Automattic\VIP\Telemetry\Tracks; + +new Pendo( 'myplugin_', [ /* global properties */ ] ); +new Tracks( 'myplugin_', [ /* global properties */ ] ); +``` diff --git a/telemetry/class-telemetry-event.php b/telemetry/class-telemetry-event.php index 108e28c5cf..f4aad5f3fd 100644 --- a/telemetry/class-telemetry-event.php +++ b/telemetry/class-telemetry-event.php @@ -16,12 +16,59 @@ * Base class for all telemetry event implementations. */ abstract class Telemetry_Event implements JsonSerializable { + /** + * Variable containing the event's data or a WP_Error if an error was + * encountered during the event's creation or validation. + * + * @var object|WP_Error + */ + protected object $data; + + /** + * Generate the event data. + * + * @return object|WP_Error A serializable object if the event is valid, otherwise a WP_Error + */ + abstract protected function generate(): object; + + /** + * Wraps generate() and stores the result to prevent multiple calls. + * + * @return object|WP_Error A serializable object if the event is valid, otherwise a WP_Error + */ + public function get_data(): object { + if ( ! isset( $this->data ) ) { + $this->data = $this->generate(); + } + + return $this->data; + } /** * Returns whether the event can be recorded. * * @return bool|WP_Error True if the event is recordable. - * WP_Error is any error occurred. */ - abstract public function is_recordable(); + public function is_recordable(): bool|WP_Error { + $data = $this->get_data(); + + if ( is_wp_error( $data ) ) { + return $data; + } + + return true; + } + + /** + * 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; + } } diff --git a/telemetry/class-telemetry.php b/telemetry/class-telemetry.php new file mode 100644 index 0000000000..f8896df003 --- /dev/null +++ b/telemetry/class-telemetry.php @@ -0,0 +1,75 @@ + + */ + private array $systems = []; + + /** + * Telemetry constructor. + * + * @param string|null $event_prefix The prefix for all event names, or null to use the default prefix. + * @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 Telemetry_Client|null $client The client instance to use. Falls back to the default client when none provided. + */ + public function __construct( string $event_prefix = null, array $global_event_properties, Telemetry_Event_Queue $queue = null, Telemetry_Client $client = null ) { + $this->systems = [ + new Pendo( $event_prefix, $global_event_properties, $queue, $client ), + new Tracks( $event_prefix, $global_event_properties, $queue, $client ), + ]; + } + + /** + * Records an event to the configured telemetry systems. + * + * 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 all events succeeded. + * False if any of the telemetry systems are disabled. + * WP_Error if any event recording failed. + */ + public function record_event( + string $event_name, + array $event_properties = [] + ): bool|WP_Error { + $return_value = true; + + foreach ( $this->systems as $system ) { + $result = $system->record_event( $event_name, $event_properties ); + + // If any system fails to record the event, return the error. + if ( is_wp_error( $result ) ) { + return $result; + } + + $return_value = $return_value && $result; + } + + return $return_value; + } +} diff --git a/telemetry/pendo/class-pendo-track-client.php b/telemetry/pendo/class-pendo-track-client.php new file mode 100644 index 0000000000..72030468f8 --- /dev/null +++ b/telemetry/pendo/class-pendo-track-client.php @@ -0,0 +1,104 @@ +api_key = $api_key; + $this->http = $http ?? _wp_http_get_object(); + } + + /** + * Record a batch of Track events using the Pendo API + * + * @param Pendo_Track_Event[] $events Array of Pendo_Track_Event objects to record + * @return bool|WP_Error True on success or WP_Error if any error occured. + */ + public function batch_record_events( array $events, array $common_props = [] ): bool|WP_Error { + if ( empty( $this->api_key ) ) { + log2logstash( [ + 'severity' => 'error', + 'feature' => 'telemetry', + 'message' => 'Pendo Track secret key is not defined', + ] ); + return new WP_Error( 'pendo_track_secret_key_not_defined', 'Pendo Track secret key is not defined' ); + } + + // Filter out invalid events. + $valid_events = array_filter( $events, function ( $event ) { + return $event instanceof Pendo_Track_Event && $event->is_recordable() === true; + } ); + + // No events? Nothing to do. + if ( empty( $valid_events ) ) { + return true; + } + + // Pendo's API does not accept bulk Track events, so we need to send them one + // at a time. :/ + foreach ( $valid_events as $body ) { + $response = $this->http->post( + self::PENDO_ENDPOINT, + [ + 'body' => wp_json_encode( $body ), + 'user-agent' => 'viptelemetry', + 'headers' => array( + 'Content-Type' => 'application/json', + 'x-pendo-integration-key' => $this->api_key, + ), + ] + ); + + // Short-circuit on the first error. + if ( is_wp_error( $response ) ) { + log2logstash( [ + 'severity' => 'error', + 'feature' => 'telemetry', + 'message' => 'Error recording events to Pendo', + 'extra' => [ + 'error' => $response->get_error_messages(), + ], + ] ); + + return $response; + } + } + + return true; + } +} diff --git a/telemetry/pendo/class-pendo-track-event-dto.php b/telemetry/pendo/class-pendo-track-event-dto.php new file mode 100644 index 0000000000..bc74e4942b --- /dev/null +++ b/telemetry/pendo/class-pendo-track-event-dto.php @@ -0,0 +1,38 @@ +|array $event_context The event's context. + * @param array|array $event_properties Any properties included in the event. + */ + public function __construct( string $prefix, string $event_name, array $event_context = [], array $event_properties = [] ) { + $this->prefix = $prefix; + $this->event_name = $event_name; + $this->event_context = $event_context; + $this->event_properties = $event_properties; + $this->event_timestamp = round( microtime( true ) * 1000 ); + } + + /** + * Returns the event's data. + * + * @return Pendo_Track_Event_DTO|WP_Error Event DTO if the event was created successfully, WP_Error otherwise. + */ + protected function generate(): Pendo_Track_Event_DTO|WP_Error { + $event_dto = new Pendo_Track_Event_DTO(); + $user_properties = get_base_properties_of_pendo_user(); + + if ( null === $user_properties ) { + return $this->log_and_return_error( $event_dto, __( 'User properties are missing', 'vip-telemetry' ), 'empty_user_information' ); + } + + // Set event name. If the event name doesn't have the prefix, add it. + $event_name = preg_replace( + '/^(?:' . $this->prefix . ')?(.+)/', + $this->prefix . '\1', + $this->event_name + ) ?? ''; + + $event_dto->accountId = $user_properties['account_id']; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $event_dto->event = $event_name; + $event_dto->timestamp = $this->event_timestamp; + $event_dto->visitorId = $user_properties['visitor_id']; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + + $event_dto->properties = (object) $this->flatten_properties_to_strings( $this->event_properties ); + $event_dto->context = (object) $this->flatten_properties_to_strings( $this->event_context ); + + $validation_error = $this->validate( $event_dto ); + + return $validation_error ?? $event_dto; + } + + /** + * Flattens an associative array of properties, JSON-encoding the values if + * they are not strings. + * + * @param array $properties The properties to flatten. + * @return array The flattened properties. + */ + private function flatten_properties_to_strings( array $properties ): array { + $flattened = []; + + foreach ( $properties as $key => $value ) { + $flattened[ $key ] = is_string( $value ) || is_bool( $value ) || is_int( $value ) ? $value : wp_json_encode( $value ); + } + + return $flattened; + } + + private function is_snake_case( string $value ): bool { + return (bool) preg_match( static::SNAKE_CASE_REGEX, $value ); + } + + private function log_and_return_error( Pendo_Track_Event_DTO $event_dto, string $msg, string $code = 'invalid_event' ): WP_Error { + log2logstash( [ + 'severity' => 'error', + 'feature' => 'telemetry', + 'message' => $msg, + 'extra' => [ + 'event' => (array) $event_dto, + ], + ] ); + + return new WP_Error( $code, $msg ); + } + + /** + * Validates the event DTO. + * + * @param Pendo_Track_Event_DTO $event Event object to validate. + * @return ?WP_Error null if validation passed, error otherwise. + */ + private function validate( Pendo_Track_Event_DTO $event_dto ): ?WP_Error { + if ( ! $event_dto->event ) { + return $this->log_and_return_error( $event_dto, __( 'The event name must be a non-empty value', 'vip-telemetry' ), 'invalid_event_name' ); + } + + if ( ! $this->is_snake_case( $event_dto->event ) ) { + return $this->log_and_return_error( $event_dto, __( 'The event name must be a non-empty value', 'vip-telemetry' ), 'invalid_event_name' ); + } + + // Validate context names against allow list. + $context_keys = array_keys( get_object_vars( $event_dto->context ) ); + $allowed_context_keys = [ 'title', 'url', 'userAgent' ]; + if ( ! empty( array_diff( $context_keys, $allowed_context_keys ) ) ) { + return $this->log_and_return_error( $event_dto, __( 'Invalid context name specified', 'vip-telemetry' ), 'invalid_context_name' ); + } + + // Validate property names format. + foreach ( array_keys( get_object_vars( $event_dto->properties ) ) as $key ) { + if ( ! $this->is_snake_case( $key ) ) { + return $this->log_and_return_error( $event_dto, __( 'A valid property name must be specified', 'vip-telemetry' ), 'invalid_property_name' ); + } + } + + return null; + } +} diff --git a/telemetry/pendo/class-pendo.php b/telemetry/pendo/class-pendo.php new file mode 100644 index 0000000000..a478894496 --- /dev/null +++ b/telemetry/pendo/class-pendo.php @@ -0,0 +1,183 @@ + + */ + private array $event_context; + + /** + * The prefix for all event names. + * + * @var string + */ + protected string $event_prefix; + + /** + * The global event properties to be included with every event. + * + * @var array + */ + private array $global_event_properties; + + /** + * Whether Pendo is enabled in the current environment. + * + * @var bool + */ + private bool $is_enabled = false; + + /** + * Event queue. + * + * @var Telemetry_Event_Queue + */ + private Telemetry_Event_Queue $queue; + + /** + * Pendo constructor. + * + * @param string $event_prefix The prefix for all event names. Defaults to 'vip_wordpress_'. + * @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. + * @param string $track_event_secret_key The Pendo track event secret key, if not defined in the environment. + */ + public function __construct( + string $event_prefix = 'vip_wordpress_', + array $global_event_properties = [], + Telemetry_Event_Queue $queue = null, + Pendo_Track_Client $client = null, + string|null $track_event_secret_key = null, + ) { + if ( null === $track_event_secret_key && defined( 'PENDO_TRACK_SECRET_KEY' ) ) { + $track_event_secret_key = constant( 'PENDO_TRACK_SECRET_KEY' ); + } + + $this->event_context = $this->get_event_context(); + $this->event_prefix = $event_prefix; + $this->global_event_properties = $this->get_global_event_properties( $global_event_properties ); + $this->is_enabled = self::is_pendo_enabled_for_environment(); + $client ??= new Pendo_Track_Client( $track_event_secret_key ); + $this->queue = $queue ?? new Telemetry_Event_Queue( $client ); + } + + /** + * Get the event context passed on every Track event as a sibling to the event + * properties. We do not ship IP address or page title. + * + * https://engageapi.pendo.io/#e45be48e-e01f-4f0a-acaa-73ef6851c4ac + * + * @return array + */ + private function get_event_context(): array { + return [ + // Provide the URL path without the origin. + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Passing value to Pendo via request body. + 'url' => wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH ) || '/', + + // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__HTTP_USER_AGENT__, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Passing value to Pendo via request body. + 'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', + ]; + } + + /** + * Get the global event properties to be included with every event. In addition + * to the global properties provided to the constructor, we include additional + * properties related to the current environment. + * + * @param array $provided_global_event_properties The global event properties provided to the constructor. + * @return array + */ + private function get_global_event_properties( array $provided_global_event_properties ): array { + return array_merge( + $provided_global_event_properties, + get_base_properties_of_pendo_track_event(), + ); + } + + /** + * Checks if Pendo telemetry is allowed in the current environment. + * + * @return bool + */ + private static function is_pendo_enabled_for_environment(): bool { + // Do not run if disabled via constant. + if ( defined( 'VIP_DISABLE_PENDO_TELEMETRY' ) && true === constant( 'VIP_DISABLE_PENDO_TELEMETRY' ) ) { + return false; + } + + // Limit to VIP environments. + if ( ! defined( 'WPCOM_IS_VIP_ENV' ) || true !== constant( 'WPCOM_IS_VIP_ENV' ) ) { + return false; + } + + // Limit to production environments. + if ( ! defined( 'VIP_GO_APP_ENVIRONMENT' ) || 'production' !== constant( 'VIP_GO_APP_ENVIRONMENT' ) ) { + return false; + } + + // Do not run on sandboxed environments. + if ( defined( 'WPCOM_SANDBOXED' ) && true === constant( 'WPCOM_SANDBOXED' ) ) { + return false; + } + + // Do not run in FedRAMP environments. + if ( defined( 'VIP_IS_FEDRAMP' ) && true === constant( 'VIP_IS_FEDRAMP' ) ) { + return false; + } + + return true; + } + + /** + * Records a "Track event" to Pendo. + * + * This name is confusing and has nothing to do with Automattic Tracks! It is + * the name chosen by Pendo for their user interaction events. They are + * distinct from what they interchangely call Page events, Feature events, or + * "low-code events". + * + * If the event doesn't pass validation, it gets silently discarded. + * + * @param string $event_name The event name. Must be snake_case. + * @param 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->is_enabled ) { + return false; + } + + $event_properties = array_merge( $this->global_event_properties, $event_properties ); + $event = new Pendo_Track_Event( $this->event_prefix, $event_name, $this->event_context, $event_properties ); + + return $this->queue->record_event_asynchronously( $event ); + } +} diff --git a/telemetry/pendo/pendo-utils.php b/telemetry/pendo/pendo-utils.php new file mode 100644 index 0000000000..c9053e3bb4 --- /dev/null +++ b/telemetry/pendo/pendo-utils.php @@ -0,0 +1,77 @@ + The base properties. + */ +function get_base_properties_of_pendo_track_event(): array { + return array_merge( + get_base_properties_of_track_event(), + [ + 'environment_type' => defined( 'WP_ENVIRONMENT_TYPE' ) ? constant( 'WP_ENVIRONMENT_TYPE' ) : 'unknown', + 'mu_plugins_version' => get_mu_plugins_version(), + ] + ); +} + +/** + * Returns the base properties for a user in the context of a Pendo event. Reuse + * the corresponding Tracks function to reduce code duplication. + * + * @return null|array The base properties. + */ +function get_base_properties_of_pendo_user(): array|null { + if ( ! function_exists( 'wp_get_current_user' ) ) { + return null; + } + + // Only track logged-in users. + $wp_user = wp_get_current_user(); + if ( 0 === $wp_user->ID ) { + return null; + } + + $event_props = get_base_properties_of_pendo_track_event(); + $user_props = get_base_properties_of_track_user(); + + $is_vip_user = $event_props['is_vip_user']; + $user_id = $is_vip_user ? 'vip-' . $user_props['_ui'] : $user_props['_ui']; + $vip_org_id = $event_props['vip_org'] ?? 'unknown'; + $sf_org_id = defined( 'VIP_SF_ACCOUNT_ID' ) ? constant( 'VIP_SF_ACCOUNT_ID' ) : sprintf( 'nosfid_wordpress_%s', $vip_org_id ); + + return [ + 'account_id' => (string) $sf_org_id, + 'country_code' => sanitize_text_field( $_SERVER['GEOIP_COUNTRY_CODE'] ?? 'unknown' ), + 'org_id' => (string) $vip_org_id, + 'role_wordpress' => $wp_user->roles[0], // The suffix helps prevent collisions with other contexts like the VIP Dashboard. + 'visitor_id' => (string) $user_id, + 'visitor_name' => $wp_user->display_name, + ]; +} + +/** + * Returns the version of VIP mu-plugins. + * + * @return string The version. + */ +function get_mu_plugins_version(): string { + if ( defined( 'WPVIP_MU_PLUGIN_DIR' ) && file_exists( constant( 'WPVIP_MU_PLUGIN_DIR' ) . '/.version' ) ) { + return file_get_contents( constant( 'WPVIP_MU_PLUGIN_DIR' ) . '/.version' ); + } + + return 'unknown'; +} diff --git a/telemetry/tracks/class-tracks-event.php b/telemetry/tracks/class-tracks-event.php index bcf2fb1e81..51c209aab1 100644 --- a/telemetry/tracks/class-tracks-event.php +++ b/telemetry/tracks/class-tracks-event.php @@ -52,14 +52,6 @@ class Tracks_Event extends Telemetry_Event { */ private float $event_timestamp; - /** - * Variable containing the event's data or an error if one was encountered - * during the event's creation. - * - * @var Tracks_Event_DTO|WP_Error - */ - protected Tracks_Event_DTO|WP_Error $data; - /** * Constructor. * @@ -79,43 +71,11 @@ public function __construct( string $prefix, string $event_name, array $event_pr * * @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; - } + protected function generate(): Tracks_Event_DTO|WP_Error { + $event_data = $this->process_properties( $this->prefix, $this->event_name, $this->event_properties ); + $validation_result = $this->get_event_validation_result( $event_data ); - return true; + return $validation_result ?? $event_data; } /** diff --git a/telemetry/class-tracks.php b/telemetry/tracks/class-tracks.php similarity index 100% rename from telemetry/class-tracks.php rename to telemetry/tracks/class-tracks.php diff --git a/tests/mock-constants.php b/tests/mock-constants.php index 2e4da4526d..bbfc8fd909 100644 --- a/tests/mock-constants.php +++ b/tests/mock-constants.php @@ -249,6 +249,38 @@ function constant( $constant ) { } } +namespace Automattic\VIP\Telemetry { + 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 ); + } +} + +namespace Automattic\VIP\Telemetry\Pendo { + 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 ); + } +} + namespace Automattic\VIP\Telemetry\Tracks { use Automattic\Test\Constant_Mocker; diff --git a/tests/telemetry/pendo/test-class-pendo-client.php b/tests/telemetry/pendo/test-class-pendo-client.php new file mode 100644 index 0000000000..f4687d6c90 --- /dev/null +++ b/tests/telemetry/pendo/test-class-pendo-client.php @@ -0,0 +1,92 @@ +getMockBuilder( WP_Http::class ) + ->disableOriginalConstructor() + ->getMock(); + + $event = $this->getMockBuilder( Pendo_Track_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( Pendo_Track_Event::class ) + ->disableOriginalConstructor() + ->getMock(); + + $bad_event->expects( $this->once() )->method( 'is_recordable' )->willReturn( false ); + + $http->expects( $this->once() ) + ->method( 'post' ) + ->with( 'https://app.pendo.io/data/track', [ + 'body' => wp_json_encode( [ + 'test_event' => true, + ] ), + 'user-agent' => 'viptelemetry', + 'headers' => array( + 'Content-Type' => 'application/json', + 'x-pendo-integration-key' => 'test_api_key', + ), + ] ) + ->willReturn( true ); + + $client = new Pendo_Track_Client( 'test_api_key', $http ); + $this->assertTrue( $client->batch_record_events( [ $event, $bad_event ], [ 'foo' => 'bar' ] ) ); + } + + public function test_should_handle_failed_requests() { + /** @var MockObject|WP_Http */ + $http = $this->getMockBuilder( WP_Http::class ) + ->disableOriginalConstructor() + ->getMock(); + + $event = $this->getMockBuilder( Pendo_Track_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( 'https://app.pendo.io/data/track' ) + ->willReturn( $error ); + + $client = new Pendo_Track_Client( 'test_api_key', $http ); + $this->assertSame( $error, $client->batch_record_events( [ $event ], [ 'foo' => 'bar' ] ) ); + } + + public function test_should_not_make_requests_for_no_events() { + /** @var MockObject|WP_Http */ + $http = $this->getMockBuilder( WP_Http::class ) + ->disableOriginalConstructor() + ->getMock(); + + $bad_event = $this->getMockBuilder( Pendo_Track_Event::class ) + ->disableOriginalConstructor() + ->getMock(); + + $bad_event->expects( $this->once() )->method( 'is_recordable' )->willReturn( false ); + + $http->expects( $this->never() ) + ->method( 'post' ); + + $client = new Pendo_Track_Client( 'test_api_key', $http ); + $this->assertTrue( $client->batch_record_events( [ $bad_event ], [ 'foo' => 'bar' ] ) ); + } +} diff --git a/tests/telemetry/pendo/test-class-pendo-track-event.php b/tests/telemetry/pendo/test-class-pendo-track-event.php new file mode 100644 index 0000000000..f1754673a1 --- /dev/null +++ b/tests/telemetry/pendo/test-class-pendo-track-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 Pendo_Track_Event( 'prefix_', 'test_event' ); + + $this->assertInstanceOf( Pendo_Track_Event::class, $event ); + } + + public function test_should_return_event_data() { + Constant_Mocker::define( 'VIP_TELEMETRY_SALT', self::VIP_TELEMETRY_SALT ); + Constant_Mocker::define( 'VIP_ORG_ID', self::VIP_ORG_ID ); + Constant_Mocker::define( 'VIP_SF_ACCOUNT_ID', self::VIP_SF_ACCOUNT_ID ); + Constant_Mocker::define( 'WP_ENVIRONMENT_TYPE', self::WP_ENVIRONMENT_TYPE ); + + $event_context = [ + 'url' => 'http://test.cool/page', + 'userAgent' => 'Cool browser 2.0', + ]; + $event_properties = array_merge( + get_base_properties_of_pendo_track_event(), + [ + 'property1' => 'value1', + 'property2' => 'value2', + ] + ); + + $event = new Pendo_Track_Event( 'prefix_', 'test_event', $event_context, $event_properties ); + + 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( Pendo_Track_Event_DTO::class, $event->get_data() ); + + // Test core event properties. + $this->assertSame( (string) self::VIP_SF_ACCOUNT_ID, $event->get_data()->accountId ); + $this->assertSame( 'prefix_test_event', $event->get_data()->event ); + $this->assertIsFloat( $event->get_data()->timestamp ); + $this->assertGreaterThan( ( time() - 10 ) * 1000, $event->get_data()->timestamp ); + $this->assertSame( 'track', $event->get_data()->type ); + $this->assertSame( hash_hmac( 'sha256', $this->user->user_email, self::VIP_TELEMETRY_SALT ), $event->get_data()->visitorId ); + + // Test event context. + $this->assertSame( 'http://test.cool/page', $event->get_data()->context->url ); + $this->assertSame( 'Cool browser 2.0', $event->get_data()->context->userAgent ); + + // Test default event properties. + $this->assertSame( self::WP_ENVIRONMENT_TYPE, $event->get_data()->properties->environment_type ); + $this->assertSame( 'other', $event->get_data()->properties->hosting_provider ); + $this->assertSame( is_multisite(), $event->get_data()->properties->is_multisite ); + $this->assertSame( 'unknown', $event->get_data()->properties->mu_plugins_version ); + $this->assertSame( get_bloginfo( 'version' ), $event->get_data()->properties->wp_version ); + $this->assertFalse( $event->get_data()->properties->is_vip_user ); + + // Test passed event properties. + $this->assertSame( 'value1', $event->get_data()->properties->property1 ); + $this->assertSame( 'value2', $event->get_data()->properties->property2 ); + + $this->assertTrue( $event->is_recordable() ); + } + + public function test_should_not_add_prefix_twice() { + $event = new Pendo_Track_Event( 'prefixed_', 'prefixed_event_name' ); + + $this->assertNotInstanceOf( WP_Error::class, $event->get_data() ); + + $this->assertSame( 'prefixed_event_name', $event->get_data()->event ); + } + + public function test_should_encode_complex_properties() { + $event = new Pendo_Track_Event( 'prefix_', 'event_name', [], [ 'example' => [ 'a' => 'b' ] ] ); + + $this->assertNotInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertSame( '{"a":"b"}', $event->get_data()->properties->example ); + } + + public function test_should_not_encode_errors_to_json() { + $event = new Pendo_Track_Event( 'prefix_', 'bogus name' ); + + $this->assertInstanceOf( WP_Error::class, $event->get_data() ); + + $this->assertSame( '{}', wp_json_encode( $event ) ); + } + + public function test_should_not_record_events_for_logged_out_users() { + wp_set_current_user( 0 ); + + $event = new Pendo_Track_Event( 'prefix_', 'test_event' ); + + $this->assertInstanceOf( WP_Error::class, $event->get_data() ); + $this->assertSame( 'empty_user_information', $event->get_data()->get_error_code() ); + } + + public function test_should_return_error_on_missing_event_name() { + $event = new Pendo_Track_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_name', $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 Pendo_Track_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_context_names() { + yield 'empty' => [ '' ]; + yield 'not allowed' => [ 'cool property' ]; + } + + /** + * @dataProvider provide_invalid_property_names + */ + public function test_should_return_error_on_invalid_context_name( string $context_name ) { + $event = new Pendo_Track_Event( 'prefix_', 'test_event', [ $context_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_context_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 Pendo_Track_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/tests/telemetry/pendo/test-class-pendo.php b/tests/telemetry/pendo/test-class-pendo.php new file mode 100644 index 0000000000..15588ff4f1 --- /dev/null +++ b/tests/telemetry/pendo/test-class-pendo.php @@ -0,0 +1,202 @@ +factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->never() ) + ->method( 'record_event_asynchronously' ); + + $pendo = new Pendo( 'test_', [], $queue, null, 'test-secret' ); + + $this->assertFalse( $pendo->record_event( 'cool_event', [ 'foo' => 'bar' ] ) ); + $this->assertFalse( self::get_property( 'is_enabled' )->getValue( $pendo ) ); + } + + public function test_disabled_by_constant() { + $user = $this->factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + Constant_Mocker::define( 'VIP_DISABLE_PENDO_TELEMETRY', true ); + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', 'production' ); + Constant_Mocker::define( 'WPCOM_IS_VIP_ENV', true ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->never() ) + ->method( 'record_event_asynchronously' ); + + $pendo = new Pendo( 'test_', [], $queue, null, 'test-secret' ); + + $this->assertFalse( $pendo->record_event( 'cool_event', [ 'foo' => 'bar' ] ) ); + $this->assertFalse( self::get_property( 'is_enabled' )->getValue( $pendo ) ); + } + + public function test_disabled_for_fedramp() { + $user = $this->factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', 'production' ); + Constant_Mocker::define( 'VIP_IS_FEDRAMP', true ); + Constant_Mocker::define( 'WPCOM_IS_VIP_ENV', true ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->never() ) + ->method( 'record_event_asynchronously' ); + + $pendo = new Pendo( 'test_', [], $queue, null, 'test-secret' ); + + $this->assertFalse( $pendo->record_event( 'cool_event', [ 'foo' => 'bar' ] ) ); + $this->assertFalse( self::get_property( 'is_enabled' )->getValue( $pendo ) ); + } + + public function test_disabled_for_non_production() { + $user = $this->factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', 'preprod' ); + Constant_Mocker::define( 'WPCOM_IS_VIP_ENV', true ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->never() ) + ->method( 'record_event_asynchronously' ); + + $pendo = new Pendo( 'test_', [], $queue, null, 'test-secret' ); + + $this->assertFalse( $pendo->record_event( 'cool_event', [ 'foo' => 'bar' ] ) ); + $this->assertFalse( self::get_property( 'is_enabled' )->getValue( $pendo ) ); + } + + public function test_disabled_for_sandbox() { + $user = $this->factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', 'production' ); + Constant_Mocker::define( 'WPCOM_IS_VIP_ENV', true ); + Constant_Mocker::define( 'WPCOM_SANDBOXED', true ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->never() ) + ->method( 'record_event_asynchronously' ); + + $pendo = new Pendo( 'test_', [], $queue, null, 'test-secret' ); + + $this->assertFalse( $pendo->record_event( 'cool_event', [ 'foo' => 'bar' ] ) ); + $this->assertFalse( self::get_property( 'is_enabled' )->getValue( $pendo ) ); + } + + public function test_event_queued() { + $user = $this->factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', 'production' ); + Constant_Mocker::define( 'WPCOM_IS_VIP_ENV', true ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->once() ) + ->method( 'record_event_asynchronously' ) + ->with($this->callback( function ( Pendo_Track_Event $event ) { + $this->assertSame( 'test_cool_event', $event->get_data()->event ); + $this->assertSame( 'bar', $event->get_data()->properties->foo ); + $this->assertFalse( isset( $event->get_data()->properties->global_baz ) ); + + return true; + } ) ) + ->willReturn( true ); + + $pendo = new Pendo( 'test_', [], $queue, null, 'test-secret' ); + + $this->assertTrue( $pendo->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 ); + + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', 'production' ); + Constant_Mocker::define( 'WPCOM_IS_VIP_ENV', true ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->once() ) + ->method( 'record_event_asynchronously' ) + ->with($this->callback(function ( Pendo_Track_Event $event ) { + $this->assertSame( 'nice_fuzzy_event', $event->get_data()->event ); + $this->assertSame( 'bar', $event->get_data()->properties->foo ); + $this->assertSame( 'qux', $event->get_data()->properties->global_baz ); + + return true; + })) + ->willReturn( true ); + + $pendo = new Pendo( 'nice_', [ + 'global_baz' => 'qux', + 'foo' => 'default_foo', + ], $queue ); + $this->assertTrue( $pendo->record_event( 'fuzzy_event', [ 'foo' => 'bar' ] ) ); + } + + public function test_event_prefix() { + $pendo = new Pendo(); + $event_prefix = self::get_property( 'event_prefix' )->getValue( $pendo ); + $this->assertEquals( 'vip_wordpress_', $event_prefix ); + } + + public function test_custom_event_prefix() { + $pendo = new Pendo( 'test_' ); + $event_prefix = self::get_property( 'event_prefix' )->getValue( $pendo ); + $this->assertEquals( 'test_', $event_prefix ); + } + + /** + * Helper function for accessing protected properties. + */ + protected static function get_property( $name ) { + $class = new \ReflectionClass( Pendo::class ); + $property = $class->getProperty( $name ); + $property->setAccessible( true ); + return $property; + } +} diff --git a/tests/telemetry/pendo/test-pendo-utils.php b/tests/telemetry/pendo/test-pendo-utils.php new file mode 100644 index 0000000000..89a6dc2378 --- /dev/null +++ b/tests/telemetry/pendo/test-pendo-utils.php @@ -0,0 +1,53 @@ + 'test_for_fun', + 'hosting_provider' => 'wpvip', + 'is_vip_user' => false, + 'is_multisite' => is_multisite(), + 'mu_plugins_version' => 'unknown', + 'wp_version' => get_bloginfo( 'version' ), + ]; + $this->assertEquals( $props, $output ); + } + + public function test_get_base_properties_of_pendo_user(): void { + wp_set_current_user( 1 ); + + Constant_Mocker::define( 'VIP_ORG_ID', 11 ); + Constant_Mocker::define( 'VIP_TELEMETRY_SALT', 'test_salt' ); + + $output = get_base_properties_of_pendo_user(); + + $props = [ + 'account_id' => 'nosfid_wordpress_11', + 'country_code' => 'unknown', + 'org_id' => '11', + 'role_wordpress' => 'administrator', + 'visitor_id' => 'f492ac7d4b4e1b795d8ebe8a142d003fdac45e33490d47573a7b78a91a52bde9', + 'visitor_name' => 'admin', + ]; + $this->assertEquals( $props, $output ); + } +} diff --git a/tests/telemetry/test-class-tracks-event-queue.php b/tests/telemetry/test-class-telemetry-event-queue.php similarity index 100% rename from tests/telemetry/test-class-tracks-event-queue.php rename to tests/telemetry/test-class-telemetry-event-queue.php diff --git a/tests/telemetry/test-class-telemetry.php b/tests/telemetry/test-class-telemetry.php new file mode 100644 index 0000000000..1fba7c41b2 --- /dev/null +++ b/tests/telemetry/test-class-telemetry.php @@ -0,0 +1,107 @@ +factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', 'production' ); + Constant_Mocker::define( 'WPCOM_IS_VIP_ENV', true ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->exactly( 2 ) ) + ->method( 'record_event_asynchronously' ) + ->with($this->callback( function ( Telemetry_Event $event ) { + $event_data = $event->get_data(); + + $this->assertFalse( is_wp_error( $event_data ) ); + $this->assertSame( 'test_cool_event', $event_data->_en ?? $event_data->event ); + $this->assertSame( 'bar', $event_data->foo ?? $event_data->properties->foo ); + + return true; + } ) ) + ->willReturn( true ); + + $telemetry = new Telemetry( 'test_', [], $queue, null, 'test-secret' ); + + $this->assertTrue( $telemetry->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 ); + + Constant_Mocker::define( 'VIP_GO_APP_ENVIRONMENT', 'production' ); + Constant_Mocker::define( 'WPCOM_IS_VIP_ENV', true ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->exactly( 2 ) ) + ->method( 'record_event_asynchronously' ) + ->with($this->callback( function ( Telemetry_Event $event ) { + $event_data = $event->get_data(); + + $this->assertFalse( is_wp_error( $event_data ) ); + $this->assertSame( 'nice_fuzzy_event', $event_data->_en ?? $event_data->event ); + $this->assertSame( 'bar', $event_data->foo ?? $event_data->properties->foo ); + $this->assertSame( 'qux', $event_data->global_baz ?? $event_data->properties->global_baz ); + + return true; + } ) ) + ->willReturn( true ); + + $telemetry = new Telemetry( 'nice_', [ + 'global_baz' => 'qux', + 'foo' => 'default_foo', + ], $queue ); + $this->assertTrue( $telemetry->record_event( 'fuzzy_event', [ 'foo' => 'bar' ] ) ); + } + + public function test_event_queued_with_pendo_disabled() { + $user = $this->factory()->user->create_and_get(); + wp_set_current_user( $user->ID ); + + /** @var MockObject|Telemetry_Event_Queue */ + $queue = $this->getMockBuilder( Telemetry_Event_Queue::class ) + ->disableOriginalConstructor() + ->getMock(); + + $queue->expects( $this->once() ) + ->method( 'record_event_asynchronously' ) + ->with($this->callback( function ( Telemetry_Event $event ) { + $event_data = $event->get_data(); + + $this->assertFalse( is_wp_error( $event_data ) ); + $this->assertSame( 'test_cool_event', $event_data->_en ); + $this->assertSame( 'bar', $event_data->foo ); + + return true; + } ) ) + ->willReturn( true ); + + $telemetry = new Telemetry( 'test_', [], $queue, null, 'test-secret' ); + + // Returns false because at least one system is disabled. + $this->assertFalse( $telemetry->record_event( 'cool_event', [ 'foo' => 'bar' ] ) ); + } +} diff --git a/tests/telemetry/test-class-tracks.php b/tests/telemetry/tracks/test-class-tracks.php similarity index 100% rename from tests/telemetry/test-class-tracks.php rename to tests/telemetry/tracks/test-class-tracks.php diff --git a/tests/telemetry/test-tracks-utils.php b/tests/telemetry/tracks/test-tracks-utils.php similarity index 100% rename from tests/telemetry/test-tracks-utils.php rename to tests/telemetry/tracks/test-tracks-utils.php