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() ); + } +}