From 406c53d89bd9877eac0b174d0967bc3af3ed98ec Mon Sep 17 00:00:00 2001 From: Alexey Kopytko Date: Fri, 27 Sep 2024 08:25:09 +0900 Subject: [PATCH] Add base telemetry library (take 2) (#5869) This PR adds a new generic Telemetry library consisting of base classes for Automattic's Tracks system integration. This is based heavily on the existing Telemetry package under the vip-parsely directory but is meant to be more generic and can be used by other plugins, and extended with other telemetry providers. There are no singletons, and most classes have fixed responsibilities. --------- Co-authored-by: Hanif Norman Co-authored-by: Hanif Norman <1227524+hanifn@users.noreply.github.com> Co-authored-by: Alec Geatches --- 000-vip-init.php | 25 ++ telemetry/README.md | 67 ++++ telemetry/class-telemetry-client.php | 26 ++ telemetry/class-telemetry-event-queue.php | 74 ++++ telemetry/class-telemetry-event.php | 27 ++ telemetry/class-telemetry-system.php | 32 ++ telemetry/class-tracks.php | 80 ++++ telemetry/tracks/class-tracks-client.php | 86 +++++ telemetry/tracks/class-tracks-event-dto.php | 45 +++ telemetry/tracks/class-tracks-event.php | 359 ++++++++++++++++++ tests/mock-constants.php | 16 + .../test-class-tracks-event-queue.php | 39 ++ tests/telemetry/test-class-tracks.php | 81 ++++ .../tracks/test-class-tracks-client.php | 90 +++++ .../tracks/test-class-tracks-event-dto.php | 16 + .../tracks/test-class-tracks-event.php | 192 ++++++++++ 16 files changed, 1255 insertions(+) create mode 100644 telemetry/README.md create mode 100644 telemetry/class-telemetry-client.php create mode 100644 telemetry/class-telemetry-event-queue.php create mode 100644 telemetry/class-telemetry-event.php create mode 100644 telemetry/class-telemetry-system.php create mode 100644 telemetry/class-tracks.php create mode 100644 telemetry/tracks/class-tracks-client.php create mode 100644 telemetry/tracks/class-tracks-event-dto.php create mode 100644 telemetry/tracks/class-tracks-event.php create mode 100644 tests/telemetry/test-class-tracks-event-queue.php create mode 100644 tests/telemetry/test-class-tracks.php create mode 100644 tests/telemetry/tracks/test-class-tracks-client.php create mode 100644 tests/telemetry/tracks/test-class-tracks-event-dto.php create mode 100644 tests/telemetry/tracks/test-class-tracks-event.php 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/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/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() ); + } +}