diff --git a/phpunit.xml b/phpunit.xml index 254b6617..f531804a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -4,21 +4,20 @@ bootstrap="./tests/bootstrap.php" colors="true" cacheDirectory=".phpunit.cache" - defaultTestSuite="Rollbar Test Suite" -> + defaultTestSuite="Rollbar Test Suite"> ./tests/ - ./tests/Performance/ - ./tests/TestHelpers/ - ./tests/FakeDataBuilder.php - ./tests/bootstrap.php - ./tests/BaseRollbarTest.php + ./tests/Performance/ + ./tests/TestHelpers/ + ./tests/FakeDataBuilder.php + ./tests/bootstrap.php + ./tests/BaseRollbarTest.php - - ./tests/Performance/ - + + ./tests/Performance/ + @@ -27,10 +26,9 @@ - - - ./src - - - + + + src + + diff --git a/src/Config.php b/src/Config.php index 1a93ec1f..6ebd855e 100644 --- a/src/Config.php +++ b/src/Config.php @@ -2,6 +2,7 @@ namespace Rollbar; +use InvalidArgumentException; use Monolog\Handler\ErrorLogHandler; use Monolog\Handler\NoopHandler; use Monolog\Logger; @@ -12,6 +13,8 @@ use Rollbar\Senders\AgentSender; use Rollbar\Senders\CurlSender; use Rollbar\Senders\SenderInterface; +use Rollbar\Telemetry\Telemeter; +use Rollbar\Telemetry\TelemetryFilterInterface; use Throwable; use Rollbar\Senders\FluentSender; @@ -60,6 +63,7 @@ class Config 'scrub_safelist', 'timeout', 'transmit', + 'telemetry', 'custom_truncation', 'report_suppressed', 'use_error_reporting', @@ -218,6 +222,13 @@ class Config */ private bool $raiseOnError = false; + /** + * @var null|array The telemetry config. If null, telemetry is disabled. + * + * @since 4.1.0 + */ + private ?array $telemetry; + /** * @var int The maximum number of items reported to Rollbar within one * request. @@ -303,6 +314,7 @@ protected function updateConfig(array $config): void $this->setCheckIgnoreFunction($config); $this->setSendMessageTrace($config); $this->setRaiseOnError($config); + $this->setTelemetry($config); if (isset($config['included_errno'])) { $this->includedErrno = $config['included_errno']; @@ -333,7 +345,7 @@ private function setAccessToken(array $config): void if (isset($_ENV['ROLLBAR_ACCESS_TOKEN']) && !isset($config['access_token'])) { $config['access_token'] = $_ENV['ROLLBAR_ACCESS_TOKEN']; } - + $this->utilities()->validateString( $config['access_token'], "config['access_token']", @@ -443,7 +455,7 @@ private function setTransformer(array $config): void private function setMinimumLevel(array $config): void { - $this->minimumLevel = \Rollbar\Defaults::get()->minimumLevel(); + $this->minimumLevel = Defaults::get()->minimumLevel(); $override = $config['minimum_level'] ?? null; $override = array_key_exists('minimumLevel', $config) ? $config['minimumLevel'] : $override; @@ -468,7 +480,7 @@ private function setReportSuppressed(array $config): void } if (!$this->reportSuppressed) { - $this->reportSuppressed = \Rollbar\Defaults::get()->reportSuppressed(); + $this->reportSuppressed = Defaults::get()->reportSuppressed(); } } @@ -508,10 +520,24 @@ private function setRaiseOnError(array $config): void if (array_key_exists('raise_on_error', $config)) { $this->raiseOnError = $config['raise_on_error']; } else { - $this->raiseOnError = \Rollbar\Defaults::get()->raiseOnError(); + $this->raiseOnError = Defaults::get()->raiseOnError(); } } + /** + * Sets and cleans the telemetry config. + * + * @param array $config The config array. + * @return void + * + * @since 4.1.0 + */ + private function setTelemetry(array $config): void + { + $telemetry = key_exists('telemetry', $config) ? $config['telemetry']: true; + $this->telemetry = Defaults::get()->telemetry($telemetry); + } + private function setBatchSize(array $config): void { if (array_key_exists('batch_size', $config)) { @@ -746,7 +772,7 @@ protected function setupWithOptions( } if (!$this->$keyName instanceof $expectedType) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( "$keyName must be a $expectedType" ); } @@ -817,6 +843,61 @@ public function getRaiseOnError(): bool return $this->raiseOnError; } + /** + * Returns the telemetry instance or null if telemetry is disabled. + * + * @param Telemeter|null $telemeter An optional telemeter instance to scope the telemetry to. This allows us to + * keep the same telemeter instance when mutating the config. Otherwise, we would + * destroy the telemetry queue when mutating the config. + * @return Telemeter|null The telemetry instance or null if telemetry is disabled. + * + * @since 4.1.0 + */ + public function getTelemetry(?Telemeter $telemeter): ?Telemeter + { + if (null === $this->telemetry) { + return null; + } + $config = $this->telemetry; + $config['filter'] = $this->initTelemetryFilter($config['filter']); + if (null === $telemeter) { + return new Telemeter(...$config); + } + $telemeter->scope(...$config); + return $telemeter; + } + + /** + * Returns the telemetry filter instance or null if no filter is configured. + * + * @param string|null $filterClass The fully qualified class name of the telemetry filter class or null if no + * filter is configured. + * @return TelemetryFilterInterface|null + * + * @throws InvalidArgumentException if the configured filter class does not exist or does not implement + * {@see TelemetryFilterInterface}. + * + * @since 4.1.0 + */ + private function initTelemetryFilter(?string $filterClass): ?TelemetryFilterInterface + { + if (null === $filterClass) { + return null; + } + if (!class_exists($filterClass)) { + throw new InvalidArgumentException( + "Telemetry filter class $filterClass does not exist" + ); + } + $filter = new $filterClass($this->telemetry); + if (!$filter instanceof TelemetryFilterInterface) { + throw new InvalidArgumentException( + "Telemetry filter class $filterClass must implement TelemetryFilterInterface" + ); + } + return $filter; + } + public function transform( Payload $payload, Level|string $level, @@ -1058,9 +1139,9 @@ public function shouldSuppress(): bool // > the value E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | // > E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE. // https://www.php.net/manual/en/language.operators.errorcontrol.php - if (version_compare(PHP_VERSION, '8.0', 'ge') && $errorReporting === ( - E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE - )) { + if ($errorReporting === ( + E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE + )) { return true; } diff --git a/src/DataBuilder.php b/src/DataBuilder.php index 4096d5a5..55ed93d3 100644 --- a/src/DataBuilder.php +++ b/src/DataBuilder.php @@ -10,11 +10,11 @@ use Rollbar\Payload\Server; use Rollbar\Payload\Request; use Rollbar\Payload\Data; +use Rollbar\Payload\TelemetryEvent; use Rollbar\Payload\Trace; use Rollbar\Payload\Frame; use Rollbar\Payload\TraceChain; use Rollbar\Payload\ExceptionInfo; -use Rollbar\Rollbar; use Stringable; use Throwable; @@ -411,7 +411,7 @@ protected function getBody(Throwable|string|Stringable $toLog, array $context): } else { $content = $this->getMessage($toLog); } - return new Body($content, $context); + return new Body($content, $context, $this->getTelemetry()); } public function getErrorTrace(ErrorWrapper $error) @@ -562,8 +562,8 @@ protected function getMessage($toLog) return new Message( (string)$toLog, $this->sendMessageTrace ? - debug_backtrace($this->localVarsDump ? 0 : DEBUG_BACKTRACE_IGNORE_ARGS) : - null + debug_backtrace($this->localVarsDump ? 0 : DEBUG_BACKTRACE_IGNORE_ARGS) : + null ); } @@ -669,7 +669,19 @@ protected function getRequest() } return $request; } - + + /** + * Returns the array of telemetry events to be sent with the payload or null if telemetry is not enabled. + * + * @return TelemetryEvent[]|null + * + * @since 4.1.0 + */ + protected function getTelemetry(): ?array + { + return Rollbar::getTelemeter()?->copyEvents(); + } + public function parseForwardedString($forwarded) { $result = array(); @@ -913,8 +925,7 @@ protected function getPerson() try { $personData = ($this->personFunc)(); } catch (\Exception $exception) { - Rollbar::scope(array('person_fn' => null))-> - log(Level::ERROR, $exception); + Rollbar::scope(array('person_fn' => null))->log(Level::ERROR, $exception); } } @@ -1247,27 +1258,27 @@ private function stripShutdownFrames($backTrace) { foreach ($backTrace as $index => $frame) { extract($frame); - + $fatalHandlerMethod = (isset($method) - && $method === 'Rollbar\\Handlers\\FatalHandler::handle'); - + && $method === 'Rollbar\\Handlers\\FatalHandler::handle'); + $fatalHandlerClassAndFunction = (isset($class) - && $class === 'Rollbar\\Handlers\\FatalHandler' - && isset($function) - && $function === 'handle'); - + && $class === 'Rollbar\\Handlers\\FatalHandler' + && isset($function) + && $function === 'handle'); + $errorHandlerMethod = (isset($method) - && $method === 'Rollbar\\Handlers\\ErrorHandler::handle'); - + && $method === 'Rollbar\\Handlers\\ErrorHandler::handle'); + $errorHandlerClassAndFunction = (isset($class) - && $class === 'Rollbar\\Handlers\\ErrorHandler' - && isset($function) - && $function === 'handle'); - + && $class === 'Rollbar\\Handlers\\ErrorHandler' + && isset($function) + && $function === 'handle'); + if ($fatalHandlerMethod || - $fatalHandlerClassAndFunction || - $errorHandlerMethod || - $errorHandlerClassAndFunction) { + $fatalHandlerClassAndFunction || + $errorHandlerMethod || + $errorHandlerClassAndFunction) { return array_slice($backTrace, $index+1); } } diff --git a/src/Defaults.php b/src/Defaults.php index a84a5cfd..c29fb407 100644 --- a/src/Defaults.php +++ b/src/Defaults.php @@ -5,6 +5,7 @@ use Monolog\Logger; use Rollbar\Payload\Notifier; use Psr\Log\LogLevel; +use Rollbar\Telemetry\Telemeter; use Throwable; class Defaults @@ -328,12 +329,33 @@ public function minimumLevel($value = null) { return $value ?? $this->minimumLevel; } - + public function raiseOnError($value = null) { return $value ?? $this->raiseOnError; } + /** + * Returns the telemetry configuration array or null if telemetry should be disabled. + * + * @param bool|array|null $value If true or null returns the default telemetry configuration. If false returns null. + * + * @return array|null The telemetry configuration. + * + * @since 4.1.0 + */ + public function telemetry(bool|array|null $value = null): ?array + { + if (true === $value) { + return $this->telemetry; + } + if (is_array($value)) { + // Ensure that the telemetry array contains only the keys we expect and includes all the required keys. + return array_merge($this->telemetry, array_intersect_key($value, $this->telemetry)); + } + return null; + } + private $psrLevels; private $errorLevels; private $autodetectBranch = false; @@ -384,4 +406,16 @@ public function raiseOnError($value = null) private $maxItems = 10; private $minimumLevel = 0; private $raiseOnError = false; + + /** + * @var array $telemetry The default telemetry configuration. + * + * @since 4.1.0 + */ + private array $telemetry = [ + 'maxTelemetryEvents' => Telemeter::MAX_EVENTS, + 'filter' => null, + 'includeItemsInTelemetry' => true, + 'includeIgnoredItemsInTelemetry' => false, + ]; } diff --git a/src/Payload/Body.php b/src/Payload/Body.php index 66e0132b..6b870cee 100644 --- a/src/Payload/Body.php +++ b/src/Payload/Body.php @@ -9,43 +9,119 @@ class Body implements SerializerInterface { use UtilitiesTrait; + /** + * Creates a new instance of the Body class. + * + * @param ContentInterface $value The value to assign to the content property. + * @param array $extra An array to assign to the extra property. Default value is an empty + * array. + * @param TelemetryEvent[]|null $telemetry An optional array of telemetry events. Default value is null. + * @return void + * + * @since 4.1.0 The $telemetry property was added. + */ public function __construct( private ContentInterface $value, - private array $extra = array() + private array $extra = [], + private ?array $telemetry = null ) { } + /** + * Returns the main content of the payload body. + * + * @return ContentInterface + */ public function getValue(): ContentInterface { return $this->value; } + /** + * Sets the main content of the payload body. + * + * @param ContentInterface $value The value to assign to the content of the payload body. + * + * @return self + */ public function setValue(ContentInterface $value): self { $this->value = $value; return $this; } - + + /** + * Sets the array of extra data. + * + * @param array $extra The array of extra data. + * + * @return self + */ public function setExtra(array $extra): self { $this->extra = $extra; return $this; } - + + /** + * Returns the array of extra data. + * + * @return array + */ public function getExtra(): array { return $this->extra; } + /** + * Returns the array of telemetry events or null if there were none. + * + * @return TelemetryEvent[]|null + * + * @since 4.1.0 + */ + public function getTelemetry(): ?array + { + if (empty($this->telemetry)) { + return null; + } + return $this->telemetry; + } + + /** + * Sets the list of telemetry events for this payload body. + * + * @param array|null $telemetry The list of telemetry events or null if there were none. + * + * @return void + * + * @since 4.1.0 + */ + public function setTelemetry(?array $telemetry): void + { + $this->telemetry = $telemetry; + } + + /** + * Returns the JSON serializable representation of the payload body. + * + * @return array + * + * @since 4.1.0 Includes the 'telemetry' key, if it is not empty. + */ public function serialize() { $result = array(); $result[$this->value->getKey()] = $this->value; - + if (!empty($this->extra)) { $result['extra'] = $this->extra; } - + + if (!empty($this->telemetry)) { + $result['telemetry'] = $this->telemetry; + } + return $this->utilities()->serializeForRollbarInternal($result, array('extra')); } } diff --git a/src/Payload/TelemetryBody.php b/src/Payload/TelemetryBody.php new file mode 100644 index 00000000..3bb84a50 --- /dev/null +++ b/src/Payload/TelemetryBody.php @@ -0,0 +1,103 @@ +extra = $extra; + } + + /** + * Returns the array representation of the telemetry body. + * + * @return array + */ + public function serialize(): array + { + // This filters out any null or empty values. + $result = array_filter([ + 'message' => $this->message, + 'method' => $this->method, + 'url' => $this->url, + 'status_code' => $this->status_code, + 'subtype' => $this->subtype, + 'stack' => $this->stack, + 'from' => $this->from, + 'to' => $this->to, + 'start_timestamp_ms' => $this->start_timestamp_ms, + 'end_timestamp_ms' => $this->end_timestamp_ms, + ]); + + if (empty($this->extra)) { + return $result; + } + + // This keeps the extra data from overwriting the defined keys when the extra data is merged into the result. + $extra = array_diff_key($this->extra, array_fill_keys(self::DEFINED_KEYS, null)); + + return $this->utilities()->serializeForRollbarInternal(array_merge($result, $extra)); + } +} diff --git a/src/Payload/TelemetryEvent.php b/src/Payload/TelemetryEvent.php new file mode 100644 index 00000000..6754473e --- /dev/null +++ b/src/Payload/TelemetryEvent.php @@ -0,0 +1,66 @@ +timestamp)) { + $this->timestamp = floor(microtime(true) * 1000); + } + $this->body = is_array($body) ? new TelemetryBody(...$body): $body; + } + + public function serialize(): array + { + $result = array_filter([ + 'uuid' => $this->uuid, + 'source' => $this->source, + 'level' => $this->level, + 'type' => $this->type, + 'body' => $this->body->serialize(), + 'timestamp_ms' => $this->timestamp, + ]); + + return $this->utilities()->serializeForRollbarInternal($result); + } +} diff --git a/src/Rollbar.php b/src/Rollbar.php index 1261e7e3..81d9ea61 100644 --- a/src/Rollbar.php +++ b/src/Rollbar.php @@ -8,6 +8,9 @@ use Rollbar\Handlers\FatalHandler; use Rollbar\Handlers\ErrorHandler; use Rollbar\Handlers\ExceptionHandler; +use Rollbar\Payload\TelemetryBody; +use Rollbar\Payload\TelemetryEvent; +use Rollbar\Telemetry\Telemeter; use Stringable; use Throwable; @@ -42,6 +45,19 @@ class Rollbar */ private static ?ExceptionHandler $exceptionHandler = null; + /** + * The instance of the telemeter. This is null if Rollbar has not been initialized or {@see Rollbar::destroy()} has + * been called. + * + * The Telemeter instance is placed here so that it is not destroyed and the queue lost if the Rollbar config is + * changed. + * + * @var Telemeter|null + * + * @since 4.1.0 + */ + private static ?Telemeter $telemeter = null; + /** * Sets up Rollbar monitoring and logging. * @@ -92,6 +108,8 @@ public static function init( } self::setupBatchHandling(); } + + self::updateTelemeter(); } /** @@ -109,6 +127,7 @@ private static function setLogger(RollbarLogger|array $configOrLogger): void { if ($configOrLogger instanceof RollbarLogger) { self::$logger = $configOrLogger; + self::updateTelemeter(); return; } @@ -119,6 +138,19 @@ private static function setLogger(RollbarLogger|array $configOrLogger): void } self::$logger = new RollbarLogger($configOrLogger); + self::updateTelemeter(); + } + + /** + * Updates the telemeter instance with the latest configs. + * + * @return void + * + * @since 4.1.0 + */ + private static function updateTelemeter(): void + { + self::$telemeter = self::$logger->getConfig()->getTelemetry(self::$telemeter); } /** @@ -184,7 +216,11 @@ public static function scope(array $config): RollbarLogger if (is_null(self::$logger)) { return new RollbarLogger($config); } - return self::$logger->scope($config); + $logger = self::$logger->scope($config); + + // Reassign the telemeter in case the config changed. + self::updateTelemeter(); + return $logger; } /** @@ -364,6 +400,36 @@ public static function emergency(string|Stringable $message, array $context = ar self::log(Level::EMERGENCY, $message, $context); } + /** + * Captures a telemetry event that may be sent with future payloads. + * + * @param string $type The type of telemetry data. One of: "log", "network", "dom", "navigation", + * "error", or "manual". + * @param string $level The severity level of the telemetry data. One of: "critical", "error", + * "warning", "info", or "debug". + * @param array|TelemetryBody $metadata Additional data about the telemetry event. + * @param string|null $uuid The Rollbar UUID to associate with this telemetry event. + * @param int|null $timestamp When this occurred, as a unix timestamp in milliseconds. If not provided, + * the current time will be used. + * + * @return TelemetryEvent|null Returns the {@see TelemetryEvent} that was captured or null if Rollbar or the + * {@see Telemeter} has not been initialized or the event is filtered out. + * + * @since 4.1.0 + */ + public static function captureTelemetryEvent( + string $type, + string $level, + array|TelemetryBody $metadata, + string $uuid = null, + ?int $timestamp = null, + ): ?TelemetryEvent { + if (is_null(self::$logger)) { + return null; + } + return self::$logger->captureTelemetryEvent($type, $level, $metadata, $uuid, $timestamp); + } + /** * Creates a listener that monitors for exceptions. * @@ -397,6 +463,18 @@ public static function setupFatalHandling(): void self::$fatalHandler->register(); } + /** + * Returns the configured instance of the telemeter. + * + * @return Telemeter|null + * + * @since 4.1.0 + */ + public static function getTelemeter(): ?Telemeter + { + return self::$telemeter; + } + /** * Creates and returns a {@see Response} to use if Rollbar is attempted to be used prior to being initialized. * @@ -490,16 +568,20 @@ public static function getCustom(): ?array public static function configure(array $config): void { self::$logger->configure($config); + self::updateTelemeter(); } /** - * Destroys the currently stored $logger allowing for a fresh configuration. This is especially used in testing - * scenarios. + * Destroys the currently stored $logger and $telemeter allowing for a fresh configuration. This is especially used + * in testing scenarios. * * @return void + * + * @since 4.1.0 Also destroys the telemeter. */ public static function destroy(): void { self::$logger = null; + self::$telemeter = null; } } diff --git a/src/RollbarLogger.php b/src/RollbarLogger.php index acfab632..9e2e5d98 100644 --- a/src/RollbarLogger.php +++ b/src/RollbarLogger.php @@ -5,6 +5,9 @@ use Exception; use Psr\Log\InvalidArgumentException; use Psr\Log\LoggerInterface; +use Rollbar\Payload\TelemetryBody; +use Rollbar\Payload\TelemetryEvent; +use Rollbar\Telemetry\Telemeter; use Stringable; use Throwable; use Psr\Log\AbstractLogger; @@ -212,6 +215,7 @@ public function log($level, $message, array $context = array()): void * @throws Throwable Rethrown $message if it is {@see Throwable} and {@see Config::raiseOnError} is true. * * @since 4.0.0 + * @since 4.1.0 Will include the reported item in the telemetry events if applicable. */ public function report( string|Level $level, @@ -239,14 +243,29 @@ public function report( $this->verboseLogger()->info("Attempting to log: [$level] " . $message); - if ($this->config->internalCheckIgnored($level, $message)) { + $accessToken = $this->getAccessToken(); + $payload = null; + $ignored = $this->config->internalCheckIgnored($level, $message); + + // We don't want to build the payload if it is going to be ignored, so we can avoid the overhead of building it. + // This is done before adding the event to the telemetry queue, so the reported occurrence is not duplicated in + // the telemetry data. + if (!$ignored) { + $payload = $this->getPayload($accessToken, $level, $message, $context); + } + // Add the event to the telemetry queue if it is enabled, but after we have built the payload, so it is not + // duplicated in the telemetry data as well. + $telemeter = Rollbar::getTelemeter(); + if (null !== $telemeter && $telemeter->shouldIncludeItemsInTelemetry()) { + $uuid = (!$ignored)? $payload->getData()->getUuid(): null; + $telemeter->captureRollbarItem($level, $message, $context, $ignored, $uuid); + } + + if ($ignored) { $this->verboseLogger()->info('Occurrence ignored'); return new Response(0, "Ignored"); } - $accessToken = $this->getAccessToken(); - $payload = $this->getPayload($accessToken, $level, $message, $context); - if ($this->config->checkIgnored($payload, $message, $isUncaught)) { $this->verboseLogger()->info('Occurrence ignored'); $response = new Response(0, "Ignored"); @@ -283,6 +302,33 @@ public function report( return $response; } + /** + * Captures a telemetry event that may be sent with future payloads. + * + * @param string $type The type of telemetry data. One of: "log", "network", "dom", "navigation", + * "error", or "manual". + * @param string $level The severity level of the telemetry data. One of: "critical", "error", + * "warning", "info", or "debug". + * @param array|TelemetryBody $metadata Additional data about the telemetry event. + * @param string|null $uuid The Rollbar UUID to associate with this telemetry event. + * @param int|null $timestamp When this occurred, as a unix timestamp in milliseconds. If not provided, + * the current time will be used. + * + * @return TelemetryEvent|null Returns the {@see TelemetryEvent} that was captured or null if the {@see Telemeter} + * has not been initialized or the event is filtered out. + * + * @since 4.1.0 + */ + public function captureTelemetryEvent( + string $type, + string $level, + array|TelemetryBody $metadata, + string $uuid = null, + ?int $timestamp = null, + ): ?TelemetryEvent { + return Rollbar::getTelemeter()?->capture($type, $level, $metadata, $uuid, $timestamp); + } + /** * Sends and flushes the batch payload queue. * diff --git a/src/Telemetry/DataType.php b/src/Telemetry/DataType.php new file mode 100644 index 00000000..be09237b --- /dev/null +++ b/src/Telemetry/DataType.php @@ -0,0 +1,29 @@ += 8.1. + * + * @since 4.1.0 + */ +class DataType +{ + const LOG = 'log'; + + const NETWORK = 'network'; + + /** + * This is intended for use with browsers, and is only included here for API completeness. Generally, this should + * not be used in a PHP context. + */ + const DOM = 'dom'; + + const NAVIGATION = 'navigation'; + + const ERROR = 'error'; + + const MANUAL = 'manual'; +} diff --git a/src/Telemetry/Telemeter.php b/src/Telemetry/Telemeter.php new file mode 100644 index 00000000..dcf20908 --- /dev/null +++ b/src/Telemetry/Telemeter.php @@ -0,0 +1,548 @@ +maxQueueSize = max(0, min($maxTelemetryEvents, self::MAX_EVENTS)); + } + + /** + * Returns the Rollbar telemetry type that corresponds to the given PSR-3 log level. + * + * @param string $level The PSR-3 log level. + * @return string + */ + private static function getTypeFromLevel(string $level): string + { + return match ($level) { + Level::EMERGENCY, Level::ALERT, Level::CRITICAL, Level::ERROR, Level::WARNING => DataType::ERROR, + Level::NOTICE, Level::INFO => DataType::LOG, + default => DataType::MANUAL, + }; + } + + /** + * Returns the Rollbar telemetry level that corresponds to the given PSR-3 log level. + * + * @param string $level The PSR-3 log level. + * @return string + */ + private static function getLevelFromLevel(string $level): string + { + return match ($level) { + Level::EMERGENCY, Level::ALERT, Level::CRITICAL => 'critical', + Level::ERROR => 'error', + Level::WARNING => 'warning', + Level::DEBUG => 'debug', + default => 'info', + }; + } + + /** + * Reconfigures the Telemeter with the given options. See the constructor for a description of the options. + * + * @param int $maxTelemetryEvents + * @param TelemetryFilterInterface|null $filter + * @param bool $includeItemsInTelemetry + * @param bool $includeIgnoredItemsInTelemetry + * @return void + */ + public function scope( + int $maxTelemetryEvents = self::MAX_EVENTS, + ?TelemetryFilterInterface $filter = null, + bool $includeItemsInTelemetry = true, + bool $includeIgnoredItemsInTelemetry = false + ): void { + if ($maxTelemetryEvents !== $this->maxQueueSize) { + // We call this method so that the queue is truncated if necessary. + $this->setMaxQueueSize($maxTelemetryEvents); + } + if ($filter !== $this->filter) { + $this->filter = $filter; + } + if ($includeItemsInTelemetry !== $this->includeItemsInTelemetry) { + $this->includeItemsInTelemetry = $includeItemsInTelemetry; + } + if ($includeIgnoredItemsInTelemetry !== $this->includeIgnoredItemsInTelemetry) { + $this->includeIgnoredItemsInTelemetry = $includeIgnoredItemsInTelemetry; + } + } + + /** + * Returns the current queue of telemetry events. + * + * Note: this method returns a copy of the queue array, but the TelemetryEvent objects are not cloned, so modifying + * the events in the returned array will modify the events in the queue. + * + * @return TelemetryEvent[] + */ + public function copyEvents(): array + { + if (null === $this->filter || !$this->filter->filterOnRead()) { + return $this->queue; + } + $queue = []; + $filtered = 0; + foreach ($this->queue as $event) { + // The queue size needs to be calculated as the number of events in the queue minus the number of events + // that have already been filtered. + if (!$this->filter->include($event, count($this->queue) - $filtered)) { + $filtered++; + continue; + } + $queue[] = $event; + } + return $queue; + } + + /** + * Appends a telemetry event to the queue. If the queue is full, the oldest event will be discarded. + * + * Note: using this method directly will bypass any filters that have been set on the Telemeter. + * + * @param TelemetryEvent $event The telemetry event to add to the queue. + * + * @return void + */ + public function push(TelemetryEvent $event): void + { + if ($this->maxQueueSize === 0) { + return; + } + $this->queue[] = $event; + if (count($this->queue) > $this->maxQueueSize) { + array_shift($this->queue); + } + } + + /** + * Captures a telemetry event and adds it to the queue. + * + * @param string $type The type of telemetry data. One of: "log", "network", "dom", "navigation", + * "error", or "manual". + * @param string $level The severity level of the telemetry data. One of: "critical", "error", + * "warning", "info", or "debug". + * @param array|TelemetryBody $metadata Additional data about the telemetry event. + * @param string|null $uuid The Rollbar UUID to associate with this telemetry event. + * @param int|null $timestamp When this occurred, as a unix timestamp in milliseconds. If not provided, + * the current time will be used. + * + * @return TelemetryEvent|null + */ + public function capture( + string $type, + string $level, + array|TelemetryBody $metadata, + string $uuid = null, + ?int $timestamp = null, + ): ?TelemetryEvent { + if ($this->maxQueueSize === 0) { + return null; + } + $event = new TelemetryEvent($type, $level, $metadata, $timestamp); + if (null !== $uuid) { + $event->uuid = $uuid; + } + if (null !== $this->filter && !$this->filter->include($event, count($this->queue))) { + return null; + } + $this->push($event); + return $event; + } + + /** + * Captures an error as a telemetry event and adds it to the queue. + * + * @param array|string|ErrorWrapper|Throwable $error The error to capture. If a string is given, it will be used + * as the message. If an array is given, it will be used as + * the metadata body. If an ErrorWrapper is given, it will be + * parsed for the message and stack trace. + * @param string $level The severity level of the telemetry data. One of: + * "critical", "error", "warning", "info", or "debug". + * Defaults to "error". + * @param string|null $uuid The Rollbar UUID to associate with this telemetry event. + * @param int|null $timestamp When this occurred, as a unix timestamp in milliseconds. If + * not provided, the current time will be used. + * + * @return TelemetryEvent|null Returns the {@see TelemetryEvent} that was added to the queue, or null if the event + * was filtered out. + */ + public function captureError( + array|string|ErrorWrapper|Throwable $error, + string $level = 'error', + string $uuid = null, + ?int $timestamp = null, + ): ?TelemetryEvent { + if (is_string($error)) { + return $this->capture('error', $level, new TelemetryBody(message: $error), $uuid, $timestamp); + } + if ($error instanceof ErrorWrapper) { + $metadata = new TelemetryBody( + message: $error->getMessage(), + subtype: 'error', + stack: $this->stringifyBacktrace($error->getBacktrace()), + ); + return $this->capture('error', $level, $metadata, $uuid, $timestamp); + } + if ($error instanceof Throwable) { + $metadata = new TelemetryBody( + message: $error->getMessage(), + subtype: 'exception', + stack: $this->stringifyBacktrace($error->getTrace()) + ); + return $this->capture('error', $level, $metadata, $uuid, $timestamp); + } + return $this->capture('error', $level, $error, $uuid, $timestamp); + } + + /** + * Captures a log message as a telemetry event and adds it to the queue. + * + * @param string $message The log message to capture. + * @param string $level The severity level of the telemetry data. One of: "critical", "error", "warning", + * "info", or "debug". Defaults to "info". + * @param string|null $uuid The Rollbar UUID to associate with this telemetry event. + * @param int|null $timestamp When this occurred, as a unix timestamp in milliseconds. If not provided, the + * current time will be used. + * + * @return TelemetryEvent|null + */ + public function captureLog( + string $message, + string $level = 'info', + string $uuid = null, + ?int $timestamp = null, + ): ?TelemetryEvent { + return $this->capture('log', $level, new TelemetryBody(message: $message), $uuid, $timestamp); + } + + /** + * Captures a network event as a telemetry event and adds it to the queue. + * + * @param string $method The HTTP method. E.g. GET, POST, etc. + * @param string $url The URL of the request. + * @param string $status_code The HTTP status code. + * @param string $level The severity level of the telemetry data. One of: "critical", "error", "warning", + * "info", or "debug". Defaults to "info". + * @param string|null $uuid The Rollbar UUID to associate with this telemetry event. + * @param int|null $timestamp When this occurred, as a unix timestamp in milliseconds. If not provided, the + * current time will be used. + * + * @return TelemetryEvent|null + */ + public function captureNetwork( + string $method, + string $url, + string $status_code, + string $level = 'info', + string $uuid = null, + ?int $timestamp = null, + ): ?TelemetryEvent { + return $this->capture( + type: 'log', + level: $level, + metadata: new TelemetryBody( + method: $method, + url: $url, + status_code: $status_code, + ), + uuid: $uuid, + timestamp: $timestamp, + ); + } + + /** + * Captures a navigation event as a telemetry event and adds it to the queue. + * + * @param string $from The URL of the previous page. + * @param string $to The URL of the next page. + * @param string $level The severity level of the telemetry data. One of: "critical", "error", "warning", + * "info", or "debug". Defaults to "info". + * @param string|null $uuid The Rollbar UUID to associate with this telemetry event. + * @param int|null $timestamp When this occurred, as a unix timestamp in milliseconds. If not provided, the + * current time will be used. + * + * @return TelemetryEvent|null + */ + public function captureNavigation( + string $from, + string $to, + string $level = 'info', + string $uuid = null, + ?int $timestamp = null, + ): ?TelemetryEvent { + return $this->capture('log', $level, new TelemetryBody(from: $from, to: $to), $uuid, $timestamp); + } + + /** + * Add a Rollbar captured item to the telemetry queue. + * + * @param string $level The PSR-3 log level. + * @param string|Stringable|Throwable $message The message to log. + * @param array $context The context. + * @param bool $ignored Whether the item was ignored. + * @param string|null $uuid The Rollbar item UUID. + * + * @return TelemetryEvent|null + * + * @internal This method is for internal use only and may change without warning. + */ + public function captureRollbarItem( + string $level, + string|Stringable|Throwable $message, + array $context = [], + bool $ignored = false, + ?string $uuid = null, + ): ?TelemetryEvent { + if (!$this->includeItemsInTelemetry) { + return null; + } + if (!$this->includeIgnoredItemsInTelemetry && $ignored) { + return null; + } + if (null !== $this->filter && !$this->filter->includeRollbarItem($level, $message, $context, $ignored)) { + return null; + } + // Make sure to respect the PSR exception context. See https://www.php-fig.org/psr/psr-3/#13-context. + if (($context['exception'] ?? null) instanceof Throwable) { + $event = $this->captureError($context['exception'], self::getLevelFromLevel($level), $uuid); + if (null === $event) { + return null; + } + // We have both a message from the exception instance and a message. So we will use the message as the + // primary body message, and the exception message will be saved to a custom "error_message" property on + // the posted telemetry event body. + $event->body->extra['error_message'] = $event->body->message; + $event->body->message = $this->getRollbarItemMessage($message); + return $event; + } + // If the rollbar item is an exception, we should capture it as an error event. + if ($message instanceof Throwable) { + return $this->captureError($message, self::getLevelFromLevel($level), $uuid); + } + // Otherwise, we will capture it based on the level. + return $this->capture( + type: self::getTypeFromLevel($level), + level: self::getLevelFromLevel($level), + metadata: new TelemetryBody(message: $this->getRollbarItemMessage($message)), + uuid: $uuid, + ); + } + + /** + * Returns the maximum number of telemetry events that can be queued before discarding prior events. + * + * @return int + */ + public function getMaxQueueSize(): int + { + return $this->maxQueueSize; + } + + /** + * Update the maximum number of telemetry events that can be queued before discarding. NOTE: If the new max is less + * than the current number of queued events, the oldest events will be discarded. + * + * @param int $maxQueueSize The maximum number of telemetry events to queue before discarding. Must be between 0 + * and 100. + */ + public function setMaxQueueSize(int $maxQueueSize): void + { + $newMax = max(0, min($maxQueueSize, self::MAX_EVENTS)); + $queueSize = count($this->queue); + if ($queueSize > $newMax) { + array_splice($this->queue, 0, $queueSize - $newMax); + } + $this->maxQueueSize = $newMax; + } + + /** + * Returns the current number of telemetry events in the queue. + * + * @return int + */ + public function getQueueSize(): int + { + return count($this->queue); + } + + /** + * Clears the queue of all telemetry events. + * + * @return void + */ + public function clearQueue(): void + { + $this->queue = []; + } + + /** + * If true, the items caught by Rollbar will be included in the telemetry of future items sent to Rollbar. + * + * @return bool + */ + public function shouldIncludeItemsInTelemetry(): bool + { + return $this->includeItemsInTelemetry; + } + + /** + * Change whether Rollbar items should be included in the telemetry queue. + * + * @param bool $include True to include Rollbar items in the telemetry data. + */ + public function setIncludeItemsInTelemetry(bool $include): void + { + $this->includeItemsInTelemetry = $include; + } + + /** + * Returns the filter instance that is applied to telemetry items before they are added to the queue. + * + * @return TelemetryFilterInterface|null + */ + public function getFilter(): ?TelemetryFilterInterface + { + return $this->filter; + } + + /** + * Sets the filter to apply to telemetry items before they are added to the queue. This will also apply the new + * filter to any items already in the queue if $apply is true. + * + * @param TelemetryFilterInterface|null $filter A filter to apply to telemetry items before they are added to the + * queue. If null, no filter will be applied. + * @param bool $apply If true, the new filter will be applied to any items already in + * the queue. + */ + public function setFilter(?TelemetryFilterInterface $filter, bool $apply = true): void + { + $this->filter = $filter; + if (null === $filter || !$apply) { + return; + } + $tempQueue = []; + $filtered = 0; + foreach ($this->queue as $event) { + // The queue size needs to be calculated as the number of events in the queue minus the number of events + // that have already been filtered. + if (!$this->filter->include($event, count($this->queue) - $filtered)) { + $filtered++; + continue; + } + $tempQueue[] = $event; + } + $this->queue = $tempQueue; + } + + /** + * Returns true if a Rollbar captured item that has been ignored should still be included in the telemetry data. + * + * @return bool + */ + public function shouldIncludeIgnoredItemsInTelemetry(): bool + { + return $this->includeIgnoredItemsInTelemetry; + } + + /** + * Sets whether items captured by Rollbar should be included in the telemetry data even if they are ignored. + * + * @param bool $include True to include ignored items in the telemetry data. + * @return void + */ + public function setIncludeIgnoredItemsInTelemetry(bool $include): void + { + $this->includeIgnoredItemsInTelemetry = $include; + } + + /** + * Returns the message from a Rollbar reported item. + * + * @param string|Stringable|Throwable $message The message to log. + * + * @return string + */ + private function getRollbarItemMessage(string|Stringable|Throwable $message): string + { + if (is_string($message)) { + return $message; + } + if ($message instanceof Throwable) { + return $message->getMessage(); + } + // else $message is a Stringable instance + return $message->__toString(); + } + + /** + * Given a standard PHP backtrace array, returns a string representation of the backtrace. + * + * @param array $backtrace The backtrace array. + * @return string + */ + private function stringifyBacktrace(array $backtrace): string + { + $result = ''; + foreach ($backtrace as $i => $frame) { + $result .= '#' . $i . ' '; + $result .= $frame['class'] ?? ''; + $result .= $frame['type'] ?? ''; + $result .= $frame['function'] ?? ''; + if (isset($frame['args'])) { + $result .= '('; + $result .= implode(', ', array_map(fn($arg) => is_string($arg) ? $arg : gettype($arg), $frame['args'])); + $result .= ')'; + } + $result .= ' at '; + $result .= $frame['file'] ?? ''; + $result .= ':'; + $result .= $frame['line'] ?? ''; + $result .= "\n"; + } + return $result; + } +} diff --git a/src/Telemetry/TelemetryFilterInterface.php b/src/Telemetry/TelemetryFilterInterface.php new file mode 100644 index 00000000..35f2697d --- /dev/null +++ b/src/Telemetry/TelemetryFilterInterface.php @@ -0,0 +1,81 @@ + includeItemsInTelemetry` config + * option to `false`. + * 2. Ignored Rollbar items are not included in the telemetry data by changing the default value of the + * `telemetry => includeIgnoredItemsInTelemetry` config option to `true`. And the reported item is ignored + * because of its log level or PHP error reporting level. + * + * @param string $level The PSR-3 log level. + * @param string|Stringable $message The message to log. + * @param array $context The context. + * @param bool $ignored Whether the item was ignored as a result of the configuration. If false, then + * the item will not be sent to Rollbar. However, you may still want to include + * it in the telemetry data. + * + * @return bool True if the item should be included in the telemetry queue, false if it should be excluded. + */ + public function includeRollbarItem( + string $level, + string|Stringable $message, + array $context = [], + bool $ignored = false, + ): bool; + + /** + * Returns `true` if the {@see include()} method should be called not only before the event is added to the queue, + * but also before the event is sent to Rollbar. This means the {@see include()} method will be called twice for + * each event. + * + * If this method returns `false`, then the {@see include()} method will only be called before the event is added to + * the queue. + * + * @return bool + */ + public function filterOnRead(): bool; +} diff --git a/src/Truncation/TelemetryStrategy.php b/src/Truncation/TelemetryStrategy.php new file mode 100644 index 00000000..e9b11d1f --- /dev/null +++ b/src/Truncation/TelemetryStrategy.php @@ -0,0 +1,89 @@ +data(); + + // If telemetry is not enabled, then remove the telemetry data from the payload entirely. + if (null === Rollbar::getTelemeter()) { + unset($data['data']['body']['telemetry']); + $payload->encode($data); + return $payload; + } + + if (!isset($data['data']['body']['telemetry'])) { + return $payload; + } + + $data['data']['body']['telemetry'] = self::selectTelemetry($data['data']['body']['telemetry']); + $payload->encode($data); + + return $payload; + } + + /** + * Returns true if the given payload contains telemetry data. This is irrespective of whether the telemetry is + * enabled or not. + * + * @param EncodedPayload $payload The payload to truncate. + * + * @return bool + * + * @since 4.1.0 + */ + public function applies(EncodedPayload $payload): bool + { + // If the payload does not telemetry data, then this strategy does not apply. + return isset($payload->data()['data']['body']['telemetry']); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index e090f336..ef4c17c4 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -404,12 +404,18 @@ public function testReportSuppressed($errorReporting, $configKey, $configValue, public function testReportSuppressedActuallySuppressed() { + // To make sure that the suppression actually works, we need to reset the error_reporting. + $oldErrorReporting = error_reporting(E_ALL); + $config = new Config(array( 'report_suppressed' => false, "access_token" => $this->getTestAccessToken() )); $this->assertFalse($config->shouldSuppress()); $this->assertTrue(@$config->shouldSuppress()); + + // Reset the error_reporting to its original value. + error_reporting($oldErrorReporting); } public function testFilter(): void diff --git a/tests/DefaultsTest.php b/tests/DefaultsTest.php index b2f49a00..0d34e87a 100644 --- a/tests/DefaultsTest.php +++ b/tests/DefaultsTest.php @@ -5,6 +5,7 @@ use Rollbar\Payload\Level; use Rollbar\Payload\Notifier; use Psr\Log\LogLevel; +use Rollbar\Telemetry\Telemeter; use Throwable; class DefaultsTest extends BaseRollbarTest @@ -284,6 +285,50 @@ public function testRaiseOnError(): void $this->assertEquals(false, $this->defaults->raiseOnError()); } + public function testTelemetry(): void + { + $this->assertSame([ + 'maxTelemetryEvents' => Telemeter::MAX_EVENTS, + 'filter' => null, + 'includeItemsInTelemetry' => true, + 'includeIgnoredItemsInTelemetry' => false, + ], $this->defaults->telemetry(true)); + + $this->assertSame([ + 'maxTelemetryEvents' => Telemeter::MAX_EVENTS, + 'filter' => null, + 'includeItemsInTelemetry' => true, + 'includeIgnoredItemsInTelemetry' => false, + ], $this->defaults->telemetry([])); + + $this->assertNull($this->defaults->telemetry(null)); + $this->assertNull($this->defaults->telemetry(false)); + + $this->assertSame([ + 'maxTelemetryEvents' => Telemeter::MAX_EVENTS, + 'filter' => null, + 'includeItemsInTelemetry' => true, + 'includeIgnoredItemsInTelemetry' => false, + ], $this->defaults->telemetry(['foo' => 'bar'])); + + $this->assertSame([ + 'maxTelemetryEvents' => Telemeter::MAX_EVENTS, + 'filter' => null, + 'includeItemsInTelemetry' => true, + 'includeIgnoredItemsInTelemetry' => true, + ], $this->defaults->telemetry([ + 'includeItemsInTelemetry' => true, + 'includeIgnoredItemsInTelemetry' => true, + ])); + + $this->assertSame([ + 'maxTelemetryEvents' => Telemeter::MAX_EVENTS, + 'filter' => 'foo', + 'includeItemsInTelemetry' => true, + 'includeIgnoredItemsInTelemetry' => false, + ], $this->defaults->telemetry(['filter' => 'foo'])); + } + /** * @testWith ["message_level", "warning"] * ["MESSAGE_LEVEL", "warning"] diff --git a/tests/Payload/TelemetryBodyTest.php b/tests/Payload/TelemetryBodyTest.php new file mode 100644 index 00000000..3043455d --- /dev/null +++ b/tests/Payload/TelemetryBodyTest.php @@ -0,0 +1,100 @@ +message); + self::assertSame('method', $body->method); + self::assertSame('url', $body->url); + self::assertSame('status', $body->status_code); + self::assertSame('sub', $body->subtype); + self::assertSame('stack', $body->stack); + self::assertSame('from', $body->from); + self::assertSame('to', $body->to); + self::assertSame(42, $body->start_timestamp_ms); + self::assertSame(43, $body->end_timestamp_ms); + self::assertSame([ + 'extraOne' => 'foo', + 'extraTwo' => 'bar', + ], $body->extra); + + // Assert array order does not matter. + $body = new TelemetryBody(...[ + 'message' => 'message', + 'extraOne' => 'foo', + 'stack' => 'stack', + ]); + + self::assertSame('message', $body->message); + self::assertSame('foo', $body->extra['extraOne']); + self::assertSame('stack', $body->stack); + } + + public function testSerialize(): void + { + $body = new TelemetryBody( + message: 'message', + method: 'method', + url: 'url', + status_code: 'status', + subtype: 'sub', + stack: 'stack', + from: 'from', + to: 'to', + start_timestamp_ms: 42, + end_timestamp_ms: 43, + extraOne: 'foo', + extraTwo: 'bar', + ); + + self::assertSame([ + 'message' => 'message', + 'method' => 'method', + 'url' => 'url', + 'status_code' => 'status', + 'subtype' => 'sub', + 'stack' => 'stack', + 'from' => 'from', + 'to' => 'to', + 'start_timestamp_ms' => 42, + 'end_timestamp_ms' => 43, + 'extraOne' => 'foo', + 'extraTwo' => 'bar', + ], $body->serialize()); + } + + public function testEmptyProperties(): void + { + $body = new TelemetryBody(); + self::assertEmpty($body->serialize()); + } + + public function testExtraDoesNotOverrideProperty(): void + { + $body = new TelemetryBody(message: 'foo'); + $body->extra['message'] = 'bar'; + + self::assertSame(['message' => 'foo'], $body->serialize()); + } +} diff --git a/tests/Payload/TelemetryEventTest.php b/tests/Payload/TelemetryEventTest.php new file mode 100644 index 00000000..4f94e1d6 --- /dev/null +++ b/tests/Payload/TelemetryEventTest.php @@ -0,0 +1,22 @@ + 'foo']); + $after = floor(microtime(true) * 1000); + + self::assertNotNull($event->timestamp); + self::assertGreaterThanOrEqual($before, $event->timestamp); + self::assertLessThanOrEqual($after, $event->timestamp); + } +} diff --git a/tests/RollbarLoggerTest.php b/tests/RollbarLoggerTest.php index 04d26032..818d89cc 100644 --- a/tests/RollbarLoggerTest.php +++ b/tests/RollbarLoggerTest.php @@ -193,6 +193,22 @@ public function testReportWithIsUncaught(): void $this->assertEquals(200, $response->getStatus()); } + public function testReportTelemetry(): void + { + // Init used so that the telemeter is initialized. + Rollbar::init([ + "access_token" => $this->getTestAccessToken(), + "environment" => "testing-php", + ]); + + Rollbar::logger()->report(Level::WARNING, "Testing PHP Notifier", isUncaught: true); + $events = Rollbar::getTelemeter()->copyEvents(); + $this->assertCount(1, $events); + $this->assertSame($events[0]->type, 'error'); + $this->assertSame($events[0]->level, 'warning'); + $this->assertSame($events[0]->body->message, 'Testing PHP Notifier'); + } + public function testDefaultVerbose(): void { $this->testNotVerbose(); diff --git a/tests/RollbarTest.php b/tests/RollbarTest.php index b4ad8c2a..642a022e 100644 --- a/tests/RollbarTest.php +++ b/tests/RollbarTest.php @@ -2,6 +2,9 @@ use Rollbar\Payload\Payload; use Rollbar\Payload\Level; +use Rollbar\Payload\TelemetryEvent; +use Rollbar\Telemetry\DataType; +use Rollbar\Telemetry\Telemeter; use Rollbar\TestHelpers\ArrayLogger; /** @@ -63,6 +66,19 @@ public function testInitReplaceLogger(): void $this->assertSame($logger, Rollbar::logger()); } + public function testInitTelemeter(): void + { + // Default telemeter is enabled + Rollbar::init(self::$simpleConfig); + + $this->assertInstanceOf(Telemeter::class, Rollbar::getTelemeter()); + + // Ensure telemeter is disabled when config is set to false + Rollbar::init(array_merge(['telemetry' => false], self::$simpleConfig)); + + $this->assertNull(Rollbar::getTelemeter()); + } + public function testLogException(): void { Rollbar::init(self::$simpleConfig); @@ -151,6 +167,22 @@ public function testEmergency(): void { $this->shortcutMethodTestHelper(Level::EMERGENCY); } + + public function testCaptureTelemetryEvent(): void + { + Rollbar::init(self::$simpleConfig); + + $event = Rollbar::captureTelemetryEvent( + type: DataType::LOG, + level: 'info', + metadata: ['message' => 'test message'], + ); + + self::assertInstanceOf(TelemetryEvent::class, $event); + self::assertEquals(DataType::LOG, $event->type); + self::assertEquals('test message', $event->body->message); + self::assertEquals('info', $event->level); + } protected function shortcutMethodTestHelper($level): void { diff --git a/tests/Telemetry/TelemeterTest.php b/tests/Telemetry/TelemeterTest.php new file mode 100644 index 00000000..71015cd5 --- /dev/null +++ b/tests/Telemetry/TelemeterTest.php @@ -0,0 +1,369 @@ +getMaxQueueSize()); + + $telemeter = new Telemeter(42); + self::assertSame(42, $telemeter->getMaxQueueSize()); + + $telemeter = new Telemeter(105); + self::assertSame(100, $telemeter->getMaxQueueSize()); + } + + public function testScope(): void + { + $telemeter = new Telemeter(); + self::assertSame(100, $telemeter->getMaxQueueSize()); + self::assertNull($telemeter->getFilter()); + self::assertTrue($telemeter->shouldIncludeItemsInTelemetry()); + self::assertFalse($telemeter->shouldIncludeIgnoredItemsInTelemetry()); + + $telemeter->scope(42, new TestTelemetryFilter(), false, true); + self::assertSame(42, $telemeter->getMaxQueueSize()); + self::assertInstanceOf(TestTelemetryFilter::class, $telemeter->getFilter()); + self::assertFalse($telemeter->shouldIncludeItemsInTelemetry()); + self::assertTrue($telemeter->shouldIncludeIgnoredItemsInTelemetry()); + } + + public function testPush(): void + { + $telemeter = new Telemeter(); + self::assertSame(100, $telemeter->getMaxQueueSize()); + self::assertSame(0, $telemeter->getQueueSize()); + + $telemeter->push(new TelemetryEvent(DataType::LOG, 'info', ['message' => 'foo'])); + self::assertSame(1, $telemeter->getQueueSize()); + + $telemeter->push(new TelemetryEvent(DataType::LOG, 'info', new TelemetryBody('bar'))); + self::assertSame(2, $telemeter->getQueueSize()); + } + + public function testCopyEvents(): void + { + $telemeter = new Telemeter(); + + $event1 = new TelemetryEvent(DataType::LOG, 'info', ['message' => 'foo']); + $event2 = new TelemetryEvent(DataType::LOG, 'info', new TelemetryBody('bar')); + + $telemeter->push($event1); + $telemeter->push($event2); + + $events = $telemeter->copyEvents(); + self::assertSame(2, count($events)); + self::assertSame($event1, $events[0]); + self::assertSame($event2, $events[1]); + } + + public function testCopyEventsFilter(): void + { + $filter = new TestTelemetryFilter(); + $filter->includeFunction = Closure::fromCallable(function (TelemetryEvent $event, int $queueSize): bool { + return $event->body->message !== 'foo'; + }); + + $telemeter = new Telemeter(filter: $filter); + + $event1 = new TelemetryEvent(DataType::LOG, 'info', ['message' => 'foo']); + $event2 = new TelemetryEvent(DataType::LOG, 'info', new TelemetryBody('bar')); + + $telemeter->push($event1); + $telemeter->push($event2); + + $events = $telemeter->copyEvents(); + self::assertSame(1, count($events)); + self::assertSame($event2, $events[0]); + } + + public function testCapture(): void + { + $telemeter = new Telemeter(); + $telemeter->capture(DataType::LOG, 'info', ['message' => 'foo']); + + $events = $telemeter->copyEvents(); + self::assertSame(1, count($events)); + self::assertSame('foo', $events[0]->body->message); + self::assertSame('info', $events[0]->level); + self::assertSame(DataType::LOG, $events[0]->type); + self::assertNotNull($events[0]->timestamp); + } + + public function testCaptureFilter(): void + { + $filter = new TestTelemetryFilter(); + $filter->includeFunction = Closure::fromCallable(function (TelemetryEvent $event, int $queueSize): bool { + return $event->body->message !== 'foo'; + }); + + $telemeter = new Telemeter(filter: $filter); + $telemeter->capture(DataType::LOG, 'info', ['message' => 'foo']); + $telemeter->capture(DataType::LOG, 'info', ['message' => 'bar']); + + // Because the filter is also applied on the copyEvents() call, we want to make sure that the 'foo' event is + // filtered out, but the 'bar' event is not. + self::assertSame(1, $telemeter->getQueueSize()); + + $events = $telemeter->copyEvents(); + self::assertSame(1, count($events)); + self::assertSame('bar', $events[0]->body->message); + } + + public function testCaptureError(): void + { + $telemeter = new Telemeter(); + $telemeter->captureError('foo'); + $telemeter->captureError(['message' => 'bar'], 'warning'); + $telemeter->captureError(['message' => 'baz'], 'critical'); + + $events = $telemeter->copyEvents(); + self::assertSame(3, count($events)); + self::assertSame('foo', $events[0]->body->message); + self::assertSame('error', $events[0]->type); + self::assertSame('error', $events[0]->level); + + self::assertSame('bar', $events[1]->body->message); + self::assertSame('error', $events[1]->type); + self::assertSame('warning', $events[1]->level); + + self::assertSame('baz', $events[2]->body->message); + self::assertSame('error', $events[2]->type); + self::assertSame('critical', $events[2]->level); + } + + public function testCaptureLog(): void + { + $telemeter = new Telemeter(); + $telemeter->captureLog('foo'); + $telemeter->captureLog('bar', 'debug'); + + $events = $telemeter->copyEvents(); + self::assertSame(2, count($events)); + self::assertSame('foo', $events[0]->body->message); + self::assertSame('log', $events[0]->type); + self::assertSame('info', $events[0]->level); + + self::assertSame('bar', $events[1]->body->message); + self::assertSame('log', $events[1]->type); + self::assertSame('debug', $events[1]->level); + } + + public function testCaptureNetwork(): void + { + $telemeter = new Telemeter(); + $telemeter->captureNetwork('POST', 'https://example.com', '200'); + + $events = $telemeter->copyEvents(); + self::assertSame(1, count($events)); + self::assertSame('POST', $events[0]->body->method); + self::assertSame('https://example.com', $events[0]->body->url); + self::assertSame('200', $events[0]->body->status_code); + } + + public function testCaptureNavigation(): void + { + $telemeter = new Telemeter(); + $telemeter->captureNavigation('https://example.com/foo', 'https://example.com/bar'); + + $events = $telemeter->copyEvents(); + self::assertSame(1, count($events)); + self::assertSame('https://example.com/foo', $events[0]->body->from); + self::assertSame('https://example.com/bar', $events[0]->body->to); + } + + public function testCaptureRollbarItem(): void + { + // Test exclude Rollbar captured item. + $telemeter = new Telemeter(includeItemsInTelemetry: false); + self::assertNull($telemeter->captureRollbarItem(Level::INFO, 'foo')); + + // Test exclude ignored Rollbar item. + $telemeter = new Telemeter(includeIgnoredItemsInTelemetry: false); + self::assertNull($telemeter->captureRollbarItem(Level::INFO, 'foo', ignored: true)); + + // Test non-ignored Rollbar item not excluded. + $event = $telemeter->captureRollbarItem(Level::INFO, 'bar'); + self::assertSame($event->body->message, 'bar'); + + // Test a Throwable in the $context['exception'] is treated as an error. + $telemeter = new Telemeter(); + $error = new Exception('oops'); + $event = $telemeter->captureRollbarItem(Level::DEBUG, 'baz', context: ['exception' => $error]); + self::assertSame(DataType::ERROR, $event->type); + self::assertSame('oops', $event->body->extra['error_message']); + self::assertSame('baz', $event->body->message); + + // Test a Throwable $message is treated as an error + $event = $telemeter->captureRollbarItem(Level::DEBUG, $error); + self::assertSame(DataType::ERROR, $event->type); + self::assertSame('oops', $event->body->message); + + // Test telemetry type dynamically determined from the Rollbar level. + self::assertSame(DataType::ERROR, $telemeter->captureRollbarItem(Level::EMERGENCY, 'foo')->type); + self::assertSame(DataType::ERROR, $telemeter->captureRollbarItem(Level::ALERT, 'foo')->type); + self::assertSame(DataType::ERROR, $telemeter->captureRollbarItem(Level::CRITICAL, 'foo')->type); + self::assertSame(DataType::ERROR, $telemeter->captureRollbarItem(Level::ERROR, 'foo')->type); + self::assertSame(DataType::ERROR, $telemeter->captureRollbarItem(Level::WARNING, 'foo')->type); + self::assertSame(DataType::LOG, $telemeter->captureRollbarItem(Level::NOTICE, 'foo')->type); + self::assertSame(DataType::LOG, $telemeter->captureRollbarItem(Level::INFO, 'foo')->type); + self::assertSame(DataType::MANUAL, $telemeter->captureRollbarItem(Level::DEBUG, 'foo')->type); + self::assertSame(DataType::MANUAL, $telemeter->captureRollbarItem('bar', 'foo')->type); + + // Test telemetry level dynamically determined from the Rollbar level. + self::assertSame('critical', $telemeter->captureRollbarItem(Level::EMERGENCY, 'foo')->level); + self::assertSame('critical', $telemeter->captureRollbarItem(Level::ALERT, 'foo')->level); + self::assertSame('critical', $telemeter->captureRollbarItem(Level::CRITICAL, 'foo')->level); + self::assertSame('error', $telemeter->captureRollbarItem(Level::ERROR, 'foo')->level); + self::assertSame('warning', $telemeter->captureRollbarItem(Level::WARNING, 'foo')->level); + self::assertSame('info', $telemeter->captureRollbarItem(Level::NOTICE, 'foo')->level); + self::assertSame('info', $telemeter->captureRollbarItem(Level::INFO, 'foo')->level); + self::assertSame('debug', $telemeter->captureRollbarItem(Level::DEBUG, 'foo')->level); + self::assertSame('info', $telemeter->captureRollbarItem('bar', 'foo')->level); + } + + public function testGetMaxQueueSize(): void + { + $telemeter = new Telemeter(42); + self::assertSame(42, $telemeter->getMaxQueueSize()); + } + + public function testSetMaxQueueSize(): void + { + $telemeter = new Telemeter(); + self::assertSame(100, $telemeter->getMaxQueueSize()); + + $telemeter->setMaxQueueSize(10); + self::assertSame(10, $telemeter->getMaxQueueSize()); + + foreach (range(1, 10) as $i) { + $telemeter->captureLog('foo' . $i); + } + + self::assertSame(10, $telemeter->getQueueSize()); + + $telemeter->setMaxQueueSize(5); + self::assertSame(5, $telemeter->getMaxQueueSize()); + self::assertSame(5, $telemeter->getQueueSize()); + } + + public function testGetQueueSize(): void + { + $telemeter = new Telemeter(); + self::assertSame(0, $telemeter->getQueueSize()); + + $telemeter->captureLog('foo'); + self::assertSame(1, $telemeter->getQueueSize()); + + $telemeter->captureLog('bar'); + self::assertSame(2, $telemeter->getQueueSize()); + } + + public function testClearQueue(): void + { + $telemeter = new Telemeter(); + $telemeter->captureLog('foo'); + $telemeter->captureLog('bar'); + + self::assertSame(2, $telemeter->getQueueSize()); + $telemeter->clearQueue(); + self::assertSame(0, $telemeter->getQueueSize()); + } + + public function testShouldIncludeItemsInTelemetry(): void + { + $telemeter = new Telemeter(); + self::assertTrue($telemeter->shouldIncludeItemsInTelemetry()); + + $telemeter = new Telemeter(includeItemsInTelemetry: false); + self::assertFalse($telemeter->shouldIncludeItemsInTelemetry()); + } + + public function testSetIncludeItemsInTelemetry(): void + { + $telemeter = new Telemeter(); + self::assertTrue($telemeter->shouldIncludeItemsInTelemetry()); + + $telemeter->setIncludeItemsInTelemetry(false); + self::assertFalse($telemeter->shouldIncludeItemsInTelemetry()); + + $telemeter->setIncludeItemsInTelemetry(true); + self::assertTrue($telemeter->shouldIncludeItemsInTelemetry()); + } + + public function testGetFilter(): void + { + $filter = new TestTelemetryFilter(); + $filter->includeFunction = Closure::fromCallable(function (TelemetryEvent $event, int $queueSize): bool { + return $event->body->message !== 'foo'; + }); + $telemeter = new Telemeter(filter: $filter); + self::assertSame($filter, $telemeter->getFilter()); + } + + public function testSetFilter(): void + { + $filter = new TestTelemetryFilter(); + $filter->includeFunction = Closure::fromCallable(function (TelemetryEvent $event, int $queueSize): bool { + return $event->body->message !== 'foo'; + }); + $telemeter = new Telemeter(); + self::assertNull($telemeter->getFilter()); + + $telemeter->setFilter($filter); + self::assertSame($filter, $telemeter->getFilter()); + } + + public function testShouldIncludeIgnoredItemsInTelemetry(): void + { + $telemeter = new Telemeter(); + self::assertFalse($telemeter->shouldIncludeIgnoredItemsInTelemetry()); + + $telemeter = new Telemeter(includeIgnoredItemsInTelemetry: false); + self::assertFalse($telemeter->shouldIncludeIgnoredItemsInTelemetry()); + + $telemeter = new Telemeter(includeIgnoredItemsInTelemetry: true); + self::assertTrue($telemeter->shouldIncludeIgnoredItemsInTelemetry()); + } + + public function testSetIncludeIgnoredItemsInTelemetry(): void + { + $telemeter = new Telemeter(); + self::assertFalse($telemeter->shouldIncludeIgnoredItemsInTelemetry()); + + $telemeter->setIncludeIgnoredItemsInTelemetry(true); + self::assertTrue($telemeter->shouldIncludeIgnoredItemsInTelemetry()); + } + + public function testSetFilterWithFilterableEvents(): void + { + $filter = new TestTelemetryFilter(); + $filter->includeFunction = Closure::fromCallable(function (TelemetryEvent $event, int $queueSize): bool { + return $event->body->message !== 'foo'; + }); + $telemeter = new Telemeter(); + + $event1 = new TelemetryEvent(DataType::LOG, 'info', ['message' => 'foo']); + $event2 = new TelemetryEvent(DataType::LOG, 'info', new TelemetryBody('bar')); + + $telemeter->push($event1); + $telemeter->push($event2); + + $telemeter->setFilter($filter); + + $events = $telemeter->copyEvents(); + self::assertSame(1, count($events)); + self::assertSame($event2, $events[0]); + } +} diff --git a/tests/TestHelpers/TestTelemetryFilter.php b/tests/TestHelpers/TestTelemetryFilter.php new file mode 100644 index 00000000..73ab5a77 --- /dev/null +++ b/tests/TestHelpers/TestTelemetryFilter.php @@ -0,0 +1,54 @@ +config = $config; + } + + /** + * @inheritDoc + */ + public function include(TelemetryEvent $event, int $queueSize): bool + { + return $this->includeFunction?->call($this, $event, $queueSize) ?? false; + } + + /** + * @inheritDoc + */ + public function includeRollbarItem( + string $level, + Stringable|string $message, + array $context = [], + bool $ignored = false, + ): bool { + return $this->includeRollbarItemFunction?->call($this, $level, $message, $context, $ignored) ?? false; + } + + /** + * @inheritDoc + */ + public function filterOnRead(): bool + { + return $this->filterOnRead; + } +} diff --git a/tests/Truncation/TelemetryStrategyTest.php b/tests/Truncation/TelemetryStrategyTest.php new file mode 100644 index 00000000..d0868431 --- /dev/null +++ b/tests/Truncation/TelemetryStrategyTest.php @@ -0,0 +1,97 @@ + $this->getTestAccessToken(), + 'environment' => 'test', + ]); + } + + /** + * @dataProvider executeProvider + */ + public function testExecute(array $data, array $expected): void + { + $config = new Config(['access_token' => $this->getTestAccessToken()]); + $truncation = new Truncation($config); + + $strategy = new TelemetryStrategy($truncation); + + $data = new EncodedPayload($data); + $data->encode(); + + $result = $strategy->execute($data); + + $this->assertEquals($expected, $result->data()); + } + + /** + * @return array + */ + public static function executeProvider(): array + { + return [ + 'nothing to truncate: no telemetry data' => [ + [ + 'data' => [ + 'body' => [], + ], + ], + [ + 'data' => [ + 'body' => [], + ], + ], + ], + 'nothing to truncate: telemetry in range' => [ + [ + 'data' => [ + 'body' => [ + 'telemetry' => range(1, 6), + ], + ], + ], + [ + 'data' => [ + 'body' => [ + 'telemetry' => range(1, 6), + ], + ], + ], + ], + 'truncate middle: telemetry too long' => [ + [ + 'data' => [ + 'body' => [ + 'telemetry' => range(1, TelemetryStrategy::TELEMETRY_OPTIMIZATION_RANGE * 2 + 1), + ], + ], + ], + [ + 'data' => [ + 'body' => [ + 'telemetry' => array_merge( + range(1, TelemetryStrategy::TELEMETRY_OPTIMIZATION_RANGE), + range( + TelemetryStrategy::TELEMETRY_OPTIMIZATION_RANGE + 2, + TelemetryStrategy::TELEMETRY_OPTIMIZATION_RANGE * 2 + 1 + ), + ), + ], + ], + ], + ], + ]; + } +}