diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e8f6d849c..5d7f1c733 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -53,12 +53,7 @@ parameters: - message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" count: 1 - path: src/Logger/DebugFileLogger.php - - - - message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" - count: 1 - path: src/Logger/DebugStdOutLogger.php + path: src/Logger/DebugLogger.php - message: "#^Parameter \\#1 \\$level of method Monolog\\\\Handler\\\\AbstractHandler\\:\\:__construct\\(\\) expects 100\\|200\\|250\\|300\\|400\\|500\\|550\\|600\\|'ALERT'\\|'alert'\\|'CRITICAL'\\|'critical'\\|'DEBUG'\\|'debug'\\|'EMERGENCY'\\|'emergency'\\|'ERROR'\\|'error'\\|'INFO'\\|'info'\\|'NOTICE'\\|'notice'\\|'WARNING'\\|'warning'\\|Monolog\\\\Level, int\\|Monolog\\\\Level\\|string given\\.$#" @@ -80,6 +75,11 @@ parameters: count: 1 path: src/Options.php + - + message: "#^Method Sentry\\\\Options\\:\\:getBeforeSendLogCallback\\(\\) should return callable\\(Sentry\\\\Logs\\\\Log\\)\\: \\(Sentry\\\\Logs\\\\Log\\|null\\) but returns mixed\\.$#" + count: 1 + path: src/Options.php + - message: "#^Method Sentry\\\\Options\\:\\:getBeforeSendMetricsCallback\\(\\) should return callable\\(Sentry\\\\Event, Sentry\\\\EventHint\\|null\\)\\: \\(Sentry\\\\Event\\|null\\) but returns mixed\\.$#" count: 1 @@ -105,6 +105,11 @@ parameters: count: 1 path: src/Options.php + - + message: "#^Method Sentry\\\\Options\\:\\:getEnableLogs\\(\\) should return bool but returns mixed\\.$#" + count: 1 + path: src/Options.php + - message: "#^Method Sentry\\\\Options\\:\\:getEnableTracing\\(\\) should return bool\\|null but returns mixed\\.$#" count: 1 diff --git a/src/Attributes/Attribute.php b/src/Attributes/Attribute.php new file mode 100644 index 000000000..c3ff48355 --- /dev/null +++ b/src/Attributes/Attribute.php @@ -0,0 +1,126 @@ +value = $value; + $this->type = $type; + } + + /** + * @return AttributeType + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return AttributeValue + */ + public function getValue() + { + return $this->value; + } + + /** + * @param mixed $value + * + * @throws \InvalidArgumentException thrown when the value cannot be serialized as an attribute + */ + public static function fromValue($value): self + { + $attribute = self::tryFromValue($value); + + if ($attribute === null) { + throw new \InvalidArgumentException(\sprintf('Invalid attribute value, %s cannot be serialized', \gettype($value))); + } + + return $attribute; + } + + /** + * @param mixed $value + */ + public static function tryFromValue($value): ?self + { + if ($value === null) { + return null; + } + + if (\is_bool($value)) { + return new self($value, 'boolean'); + } + + if (\is_int($value)) { + return new self($value, 'integer'); + } + + if (\is_float($value)) { + return new self($value, 'double'); + } + + if (\is_string($value) || (\is_object($value) && method_exists($value, '__toString'))) { + $stringValue = (string) $value; + + if (empty($stringValue)) { + return null; + } + + return new self($stringValue, 'string'); + } + + return null; + } + + /** + * @return AttributeSerialized + */ + public function toArray(): array + { + return [ + 'type' => $this->type, + 'value' => $this->value, + ]; + } + + /** + * @return AttributeSerialized + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + public function __toString(): string + { + return "{$this->value} ({$this->type})"; + } +} diff --git a/src/Attributes/AttributeBag.php b/src/Attributes/AttributeBag.php new file mode 100644 index 000000000..00cb45dc5 --- /dev/null +++ b/src/Attributes/AttributeBag.php @@ -0,0 +1,74 @@ + + */ + private $attributes = []; + + /** + * @param mixed $value + */ + public function set(string $key, $value): self + { + $attribute = $value instanceof Attribute + ? $value + : Attribute::tryFromValue($value); + + if ($attribute !== null) { + $this->attributes[$key] = $attribute; + } + + return $this; + } + + public function get(string $key): ?Attribute + { + return $this->attributes[$key] ?? null; + } + + /** + * @return array + */ + public function all(): array + { + return $this->attributes; + } + + /** + * @return array + */ + public function toArray(): array + { + return array_map(static function (Attribute $attribute) { + return $attribute->jsonSerialize(); + }, $this->attributes); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @return array + */ + public function toSimpleArray(): array + { + return array_map(static function (Attribute $attribute) { + return $attribute->getValue(); + }, $this->attributes); + } +} diff --git a/src/Client.php b/src/Client.php index 5048343ae..36ee6a1ce 100644 --- a/src/Client.php +++ b/src/Client.php @@ -255,6 +255,16 @@ public function getTransport(): TransportInterface return $this->transport; } + public function getSdkIdentifier(): string + { + return $this->sdkIdentifier; + } + + public function getSdkVersion(): string + { + return $this->sdkVersion; + } + /** * Assembles an event and prepares it to be sent of to Sentry. * @@ -280,6 +290,7 @@ private function prepareEvent(Event $event, ?EventHint $hint = null, ?Scope $sco $event->setSdkIdentifier($this->sdkIdentifier); $event->setSdkVersion($this->sdkVersion); + $event->setTags(array_merge($this->options->getTags(), $event->getTags())); if ($event->getServerName() === null) { diff --git a/src/Event.php b/src/Event.php index 51f4563b2..1ac6cc6af 100644 --- a/src/Event.php +++ b/src/Event.php @@ -6,6 +6,7 @@ use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; +use Sentry\Logs\Log; use Sentry\Profiling\Profile; use Sentry\Tracing\Span; @@ -65,6 +66,11 @@ final class Event */ private $checkIn; + /** + * @var Log[] + */ + private $logs = []; + /** * @var string|null The name of the server (e.g. the host name) */ @@ -230,6 +236,11 @@ public static function createCheckIn(?EventId $eventId = null): self return new self($eventId, EventType::checkIn()); } + public static function createLogs(?EventId $eventId = null): self + { + return new self($eventId, EventType::logs()); + } + /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ @@ -416,6 +427,24 @@ public function setCheckIn(?CheckIn $checkIn): self return $this; } + /** + * @return Log[] + */ + public function getLogs(): array + { + return $this->logs; + } + + /** + * @param Log[] $logs + */ + public function setLogs(array $logs): self + { + $this->logs = $logs; + + return $this; + } + /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ diff --git a/src/EventType.php b/src/EventType.php index b8ffdef94..3c2d13fb3 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -42,6 +42,11 @@ public static function checkIn(): self return self::getInstance('check_in'); } + public static function logs(): self + { + return self::getInstance('log'); + } + /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ @@ -61,6 +66,7 @@ public static function cases(): array self::event(), self::transaction(), self::checkIn(), + self::logs(), self::metrics(), ]; } diff --git a/src/Logger/DebugFileLogger.php b/src/Logger/DebugFileLogger.php index 4875baa75..e096b4bba 100644 --- a/src/Logger/DebugFileLogger.php +++ b/src/Logger/DebugFileLogger.php @@ -4,9 +4,7 @@ namespace Sentry\Logger; -use Psr\Log\AbstractLogger; - -class DebugFileLogger extends AbstractLogger +class DebugFileLogger extends DebugLogger { /** * @var string @@ -18,13 +16,8 @@ public function __construct(string $filePath) $this->filePath = $filePath; } - /** - * @param mixed $level - * @param string|\Stringable $message - * @param mixed[] $context - */ - public function log($level, $message, array $context = []): void + public function write(string $message): void { - file_put_contents($this->filePath, \sprintf("sentry/sentry: [%s] %s\n", $level, (string) $message), \FILE_APPEND); + file_put_contents($this->filePath, $message, \FILE_APPEND); } } diff --git a/src/Logger/DebugLogger.php b/src/Logger/DebugLogger.php new file mode 100644 index 000000000..7ebe02115 --- /dev/null +++ b/src/Logger/DebugLogger.php @@ -0,0 +1,26 @@ +write( + \sprintf("sentry/sentry: [%s] %s\n", $level, $formattedMessageAndContext) + ); + } + + abstract public function write(string $message): void; +} diff --git a/src/Logger/DebugStdOutLogger.php b/src/Logger/DebugStdOutLogger.php index 5b2da8faf..8e31845d2 100644 --- a/src/Logger/DebugStdOutLogger.php +++ b/src/Logger/DebugStdOutLogger.php @@ -4,17 +4,10 @@ namespace Sentry\Logger; -use Psr\Log\AbstractLogger; - -class DebugStdOutLogger extends AbstractLogger +class DebugStdOutLogger extends DebugLogger { - /** - * @param mixed $level - * @param string|\Stringable $message - * @param mixed[] $context - */ - public function log($level, $message, array $context = []): void + public function write(string $message): void { - file_put_contents('php://stdout', \sprintf("sentry/sentry: [%s] %s\n", $level, (string) $message)); + file_put_contents('php://stdout', $message); } } diff --git a/src/Logs/Log.php b/src/Logs/Log.php new file mode 100644 index 000000000..4e29885de --- /dev/null +++ b/src/Logs/Log.php @@ -0,0 +1,109 @@ + + * } + */ +class Log implements \JsonSerializable +{ + /** + * @var float + */ + private $timestamp; + + /** + * @var string + */ + private $traceId; + + /** + * @var LogLevel + */ + private $level; + + /** + * @var string + */ + private $body; + + /** + * @var AttributeBag + */ + private $attributes; + + public function __construct( + float $timestamp, + string $traceId, + LogLevel $level, + string $body + ) { + $this->timestamp = $timestamp; + $this->traceId = $traceId; + $this->level = $level; + $this->body = $body; + $this->attributes = new AttributeBag(); + } + + public function getTimestamp(): float + { + return $this->timestamp; + } + + public function getTraceId(): string + { + return $this->traceId; + } + + public function getLevel(): LogLevel + { + return $this->level; + } + + public function getBody(): string + { + return $this->body; + } + + public function attributes(): AttributeBag + { + return $this->attributes; + } + + /** + * @param mixed $value + */ + public function setAttribute(string $key, $value): self + { + $this->attributes->set($key, $value); + + return $this; + } + + /** + * @return LogEnvelopeItem + */ + public function jsonSerialize(): array + { + return [ + 'timestamp' => $this->timestamp, + 'trace_id' => $this->traceId, + 'level' => (string) $this->level, + 'body' => $this->body, + 'attributes' => $this->attributes->toArray(), + ]; + } +} diff --git a/src/Logs/LogLevel.php b/src/Logs/LogLevel.php new file mode 100644 index 000000000..5b12dc3d0 --- /dev/null +++ b/src/Logs/LogLevel.php @@ -0,0 +1,70 @@ + A list of cached enum instances + */ + private static $instances = []; + + private function __construct(string $value) + { + $this->value = $value; + } + + public static function trace(): self + { + return self::getInstance('trace'); + } + + public static function debug(): self + { + return self::getInstance('debug'); + } + + public static function info(): self + { + return self::getInstance('info'); + } + + public static function warn(): self + { + return self::getInstance('warn'); + } + + public static function error(): self + { + return self::getInstance('error'); + } + + public static function fatal(): self + { + return self::getInstance('fatal'); + } + + public function __toString(): string + { + return $this->value; + } + + private static function getInstance(string $value): self + { + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new self($value); + } + + return self::$instances[$value]; + } +} diff --git a/src/Logs/Logs.php b/src/Logs/Logs.php new file mode 100644 index 000000000..99bc34e43 --- /dev/null +++ b/src/Logs/Logs.php @@ -0,0 +1,112 @@ +aggregator = new LogsAggregator(); + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * @param string $message see sprintf for a description of format + * @param array $values see sprintf for a description of values + * @param array $attributes additional attributes to add to the log + */ + public function trace(string $message, array $values = [], array $attributes = []): void + { + $this->aggregator->add(LogLevel::trace(), $message, $values, $attributes); + } + + /** + * @param string $message see sprintf for a description of format + * @param array $values see sprintf for a description of values + * @param array $attributes additional attributes to add to the log + */ + public function debug(string $message, array $values = [], array $attributes = []): void + { + $this->aggregator->add(LogLevel::debug(), $message, $values, $attributes); + } + + /** + * @param string $message see sprintf for a description of format + * @param array $values see sprintf for a description of values + * @param array $attributes additional attributes to add to the log + */ + public function info(string $message, array $values = [], array $attributes = []): void + { + $this->aggregator->add(LogLevel::info(), $message, $values, $attributes); + } + + /** + * @param string $message see sprintf for a description of format + * @param array $values see sprintf for a description of values + * @param array $attributes additional attributes to add to the log + */ + public function warn(string $message, array $values = [], array $attributes = []): void + { + $this->aggregator->add(LogLevel::warn(), $message, $values, $attributes); + } + + /** + * @param string $message see sprintf for a description of format + * @param array $values see sprintf for a description of values + * @param array $attributes additional attributes to add to the log + */ + public function error(string $message, array $values = [], array $attributes = []): void + { + $this->aggregator->add(LogLevel::error(), $message, $values, $attributes); + } + + /** + * @param string $message see sprintf for a description of format + * @param array $values see sprintf for a description of values + * @param array $attributes additional attributes to add to the log + */ + public function fatal(string $message, array $values = [], array $attributes = []): void + { + $this->aggregator->add(LogLevel::fatal(), $message, $values, $attributes); + } + + /** + * Flush the captured logs and send them to Sentry. + */ + public function flush(): ?EventId + { + return $this->aggregator->flush(); + } + + /** + * Get the logs aggregator. + * + * @internal + */ + public function aggregator(): LogsAggregator + { + return $this->aggregator; + } +} diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php new file mode 100644 index 000000000..7fa9bc231 --- /dev/null +++ b/src/Logs/LogsAggregator.php @@ -0,0 +1,145 @@ + $values see sprintf for a description of values + * @param array $attributes additional attributes to add to the log + */ + public function add( + LogLevel $level, + string $message, + array $values = [], + array $attributes = [] + ): void { + $timestamp = microtime(true); + + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + // There is no need to continue if there is no client + if ($client === null) { + return; + } + + $options = $client->getOptions(); + $sdkLogger = $options->getLogger(); + + if (!$options->getEnableLogs()) { + if ($sdkLogger !== null) { + $sdkLogger->info( + 'Log will be discarded because "enable_logs" is "false".' + ); + } + + return; + } + + $log = (new Log($timestamp, $this->getTraceId($hub), $level, vsprintf($message, $values))) + ->setAttribute('sentry.release', $options->getRelease()) + ->setAttribute('sentry.environment', $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT) + ->setAttribute('sentry.server.address', $options->getServerName()) + ->setAttribute('sentry.message.template', $message) + ->setAttribute('sentry.trace.parent_span_id', $hub->getSpan() ? $hub->getSpan()->getSpanId() : null); + + if ($client instanceof Client) { + $log->setAttribute('sentry.sdk.name', $client->getSdkIdentifier()); + $log->setAttribute('sentry.sdk.version', $client->getSdkVersion()); + } + + foreach ($values as $key => $value) { + $log->setAttribute("sentry.message.parameter.{$key}", $value); + } + + $attributes = Arr::simpleDot($attributes); + + foreach ($attributes as $key => $value) { + $attribute = Attribute::tryFromValue($value); + + if ($attribute === null) { + if ($sdkLogger !== null) { + $sdkLogger->info( + \sprintf("Dropping log attribute {$key} with value of type '%s' because it is not serializable or an unsupported type.", \gettype($value)) + ); + } + } else { + $log->setAttribute($key, $attribute); + } + } + + $log = ($options->getBeforeSendLogCallback())($log); + + if ($log === null) { + if ($sdkLogger !== null) { + $sdkLogger->info( + 'Log will be discarded because the "before_send_log" callback returned "null".', + ['log' => $log] + ); + } + + return; + } + + // We check if it's a `LogsLogger` to avoid a infinite loop where the logger is logging the logs it's writing + if ($sdkLogger !== null) { + $sdkLogger->log((string) $log->getLevel(), "Logs item: {$log->getBody()}", $log->attributes()->toSimpleArray()); + } + + $this->logs[] = $log; + } + + public function flush(): ?EventId + { + if (empty($this->logs)) { + return null; + } + + $hub = SentrySdk::getCurrentHub(); + $event = Event::createLogs()->setLogs($this->logs); + + $this->logs = []; + + return $hub->captureEvent($event); + } + + private function getTraceId(HubInterface $hub): string + { + $span = $hub->getSpan(); + + if ($span !== null) { + return (string) $span->getTraceId(); + } + + $traceId = ''; + + $hub->configureScope(function (Scope $scope) use (&$traceId) { + $traceId = (string) $scope->getPropagationContext()->getTraceId(); + }); + + return $traceId; + } +} diff --git a/src/Options.php b/src/Options.php index 45dcbefaf..b4f9ba3ed 100644 --- a/src/Options.php +++ b/src/Options.php @@ -9,6 +9,7 @@ use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\ErrorListenerIntegration; use Sentry\Integration\IntegrationInterface; +use Sentry\Logs\Log; use Sentry\Transport\TransportInterface; use Symfony\Component\OptionsResolver\Options as SymfonyOptions; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -1152,6 +1153,39 @@ public function setTracesSampler(?callable $sampler): self return $this; } + /** + * Sets if logs should be enabled or not. + * + * @param bool|null $enableLogs Boolean if logs should be enabled or not + */ + public function setEnableLogs(?bool $enableLogs): self + { + $options = array_merge($this->options, ['enable_tracing' => $enableLogs]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + + /** + * Gets if logs is enabled or not. + */ + public function getEnableLogs(): bool + { + return $this->options['enable_logs'] ?? false; + } + + /** + * Gets a callback that will be invoked before an log is sent to the server. + * If `null` is returned it won't be sent. + * + * @psalm-return callable(Log): ?Log + */ + public function getBeforeSendLogCallback(): callable + { + return $this->options['before_send_log']; + } + /** * Configures the options of the client. * @@ -1229,6 +1263,10 @@ private function configureOptions(OptionsResolver $resolver): void 'capture_silenced_errors' => false, 'max_request_body_size' => 'medium', 'class_serializers' => [], + 'enable_logs' => false, + 'before_send_log' => static function (Log $log): Log { + return $log; + }, ]); $resolver->setAllowedTypes('prefixes', 'string[]'); @@ -1275,6 +1313,8 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('capture_silenced_errors', 'bool'); $resolver->setAllowedTypes('max_request_body_size', 'string'); $resolver->setAllowedTypes('class_serializers', 'array'); + $resolver->setAllowedTypes('enable_logs', 'bool'); + $resolver->setAllowedTypes('before_send_log', 'callable'); $resolver->setAllowedValues('max_request_body_size', ['none', 'never', 'small', 'medium', 'always']); $resolver->setAllowedValues('dsn', \Closure::fromCallable([$this, 'validateDsnOption'])); diff --git a/src/Serializer/EnvelopItems/EnvelopeItemInterface.php b/src/Serializer/EnvelopItems/EnvelopeItemInterface.php index d2b7d3712..bf95f6e45 100644 --- a/src/Serializer/EnvelopItems/EnvelopeItemInterface.php +++ b/src/Serializer/EnvelopItems/EnvelopeItemInterface.php @@ -11,5 +11,5 @@ */ interface EnvelopeItemInterface { - public static function toEnvelopeItem(Event $event): string; + public static function toEnvelopeItem(Event $event): ?string; } diff --git a/src/Serializer/EnvelopItems/LogsItem.php b/src/Serializer/EnvelopItems/LogsItem.php new file mode 100644 index 000000000..431e67f9f --- /dev/null +++ b/src/Serializer/EnvelopItems/LogsItem.php @@ -0,0 +1,34 @@ +getLogs(); + + $header = [ + 'type' => (string) EventType::logs(), + 'item_count' => \count($logs), + 'content_type' => 'application/vnd.sentry.items.log+json', + ]; + + return \sprintf( + "%s\n%s", + JSON::encode($header), + JSON::encode([ + 'items' => $logs, + ]) + ); + } +} diff --git a/src/Serializer/EnvelopItems/ProfileItem.php b/src/Serializer/EnvelopItems/ProfileItem.php index 646506478..512eae80f 100644 --- a/src/Serializer/EnvelopItems/ProfileItem.php +++ b/src/Serializer/EnvelopItems/ProfileItem.php @@ -13,7 +13,7 @@ */ class ProfileItem implements EnvelopeItemInterface { - public static function toEnvelopeItem(Event $event): string + public static function toEnvelopeItem(Event $event): ?string { $header = [ 'type' => 'profile', @@ -22,12 +22,12 @@ public static function toEnvelopeItem(Event $event): string $profile = $event->getSdkMetadata('profile'); if (!$profile instanceof Profile) { - return ''; + return null; } $payload = $profile->getFormattedData($event); if ($payload === null) { - return ''; + return null; } return \sprintf("%s\n%s", JSON::encode($header), JSON::encode($payload)); diff --git a/src/Serializer/EnvelopItems/TransactionItem.php b/src/Serializer/EnvelopItems/TransactionItem.php index 679bd6bcd..9eb8eab22 100644 --- a/src/Serializer/EnvelopItems/TransactionItem.php +++ b/src/Serializer/EnvelopItems/TransactionItem.php @@ -5,6 +5,7 @@ namespace Sentry\Serializer\EnvelopItems; use Sentry\Event; +use Sentry\EventType; use Sentry\Serializer\Traits\BreadcrumbSeralizerTrait; use Sentry\Tracing\Span; use Sentry\Tracing\TransactionMetadata; @@ -28,7 +29,7 @@ class TransactionItem implements EnvelopeItemInterface public static function toEnvelopeItem(Event $event): string { $header = [ - 'type' => (string) $event->getType(), + 'type' => (string) EventType::transaction(), 'content_type' => 'application/json', ]; diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index b836fe044..4878cc767 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -9,6 +9,7 @@ use Sentry\Options; use Sentry\Serializer\EnvelopItems\CheckInItem; use Sentry\Serializer\EnvelopItems\EventItem; +use Sentry\Serializer\EnvelopItems\LogsItem; use Sentry\Serializer\EnvelopItems\ProfileItem; use Sentry\Serializer\EnvelopItems\TransactionItem; use Sentry\Tracing\DynamicSamplingContext; @@ -54,28 +55,26 @@ public function serialize(Event $event): string } } - $items = ''; + $items = []; switch ($event->getType()) { case EventType::event(): - $items = EventItem::toEnvelopeItem($event); + $items[] = EventItem::toEnvelopeItem($event); break; case EventType::transaction(): - $transactionItem = TransactionItem::toEnvelopeItem($event); + $items[] = TransactionItem::toEnvelopeItem($event); if ($event->getSdkMetadata('profile') !== null) { - $profileItem = ProfileItem::toEnvelopeItem($event); - if ($profileItem !== '') { - $items = \sprintf("%s\n%s", $transactionItem, $profileItem); - break; - } + $items[] = ProfileItem::toEnvelopeItem($event); } - $items = $transactionItem; break; case EventType::checkIn(): - $items = CheckInItem::toEnvelopeItem($event); + $items[] = CheckInItem::toEnvelopeItem($event); + break; + case EventType::logs(): + $items[] = LogsItem::toEnvelopeItem($event); break; } - return \sprintf("%s\n%s", JSON::encode($envelopeHeader), $items); + return \sprintf("%s\n%s", JSON::encode($envelopeHeader), implode("\n", array_filter($items))); } } diff --git a/src/Transport/RateLimiter.php b/src/Transport/RateLimiter.php index ce0becfd1..dbb8deccf 100644 --- a/src/Transport/RateLimiter.php +++ b/src/Transport/RateLimiter.php @@ -16,6 +16,11 @@ final class RateLimiter */ private const DATA_CATEGORY_ERROR = 'error'; + /** + * @var string + */ + private const DATA_CATEGORY_LOG_ITEM = 'log_item'; + /** * The name of the header to look at to know the rate limits for the events * categories supported by the server. @@ -112,6 +117,8 @@ public function getDisabledUntil($eventType): int if ($eventType === 'event') { $eventType = self::DATA_CATEGORY_ERROR; + } elseif ($eventType === 'log') { + $eventType = self::DATA_CATEGORY_LOG_ITEM; } return max($this->rateLimits['all'] ?? 0, $this->rateLimits[$eventType] ?? 0); diff --git a/src/Util/Arr.php b/src/Util/Arr.php new file mode 100644 index 000000000..8773b6c07 --- /dev/null +++ b/src/Util/Arr.php @@ -0,0 +1,66 @@ + $array + * + * @return array + */ + public static function simpleDot(array $array): array + { + $results = []; + + $flatten = static function ($data, $prefix = '') use (&$results, &$flatten): void { + foreach ($data as $key => $value) { + $newKey = $prefix . $key; + + if (\is_array($value) && !empty($value) && !self::isList($value)) { + $flatten($value, $newKey . '.'); + } else { + $results[$newKey] = $value; + } + } + }; + + $flatten($array); + + return $results; + } + + /** + * Checks whether a given array is a list. + * + * `array_is_list` is introduced in PHP 8.1, so we have a polyfill for it. + * + * @see https://www.php.net/manual/en/function.array-is-list.php#126794 + * + * @param array $array + */ + public static function isList(array $array): bool + { + $i = 0; + + foreach ($array as $k => $v) { + if ($k !== $i++) { + return false; + } + } + + return true; + } +} diff --git a/src/functions.php b/src/functions.php index 28b0c3009..b944eb965 100644 --- a/src/functions.php +++ b/src/functions.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\IntegrationInterface; +use Sentry\Logs\Logs; use Sentry\Metrics\Metrics; use Sentry\State\Scope; use Sentry\Tracing\PropagationContext; @@ -63,6 +64,8 @@ * traces_sample_rate?: float|int|null, * traces_sampler?: callable|null, * transport?: callable, + * enable_logs?: bool, + * before_send_log?: callable, * } $options The client options */ function init(array $options = []): void @@ -362,6 +365,14 @@ function continueTrace(string $sentryTrace, string $baggage): TransactionContext return TransactionContext::fromHeaders($sentryTrace, $baggage); } +/** + * Get the Sentry Logs client. + */ +function logger(): Logs +{ + return Logs::getInstance(); +} + /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ diff --git a/tests/Attributes/AttributeBagTest.php b/tests/Attributes/AttributeBagTest.php new file mode 100644 index 000000000..397fc8cf5 --- /dev/null +++ b/tests/Attributes/AttributeBagTest.php @@ -0,0 +1,68 @@ +assertCount(0, $bag->all()); + + $bag->set('foo', 'bar'); + + $this->assertCount(1, $bag->all()); + $this->assertInstanceOf(Attribute::class, $bag->get('foo')); + + $this->assertNull($bag->get('non-existing')); + } + + public function testSerializeAsJson(): void + { + $bag = new AttributeBag(); + $bag->set('foo', 'bar'); + + $this->assertEquals( + ['foo' => ['type' => 'string', 'value' => 'bar']], + $bag->jsonSerialize() + ); + + $this->assertEquals( + '{"foo":{"type":"string","value":"bar"}}', + json_encode($bag) + ); + } + + public function testSerializeAsArray(): void + { + $bag = new AttributeBag(); + $bag->set('foo', 'bar'); + + $this->assertEquals( + ['foo' => ['type' => 'string', 'value' => 'bar']], + $bag->toArray() + ); + } + + public function testSerializeAsSimpleArray(): void + { + $bag = new AttributeBag(); + $bag->set('foo', 'bar'); + + $this->assertEquals( + ['foo' => 'bar'], + $bag->toSimpleArray() + ); + } +} diff --git a/tests/Attributes/AttributeTest.php b/tests/Attributes/AttributeTest.php new file mode 100644 index 000000000..3919e1ce0 --- /dev/null +++ b/tests/Attributes/AttributeTest.php @@ -0,0 +1,147 @@ +assertNull($attribute); + + return; + } + + $this->assertEquals($expected, $attribute->toArray()); + $this->assertEquals($expected['type'], $attribute->getType()); + $this->assertEquals($expected['value'], $attribute->getValue()); + } + + public static function fromValueDataProvider(): \Generator + { + yield [ + 'foo', + [ + 'type' => 'string', + 'value' => 'foo', + ], + ]; + + yield [ + 123, + [ + 'type' => 'integer', + 'value' => 123, + ], + ]; + + yield [ + 123.33, + [ + 'type' => 'double', + 'value' => 123.33, + ], + ]; + + yield [ + true, + [ + 'type' => 'boolean', + 'value' => true, + ], + ]; + + yield [ + new class { + public function __toString(): string + { + return 'foo'; + } + }, + [ + 'type' => 'string', + 'value' => 'foo', + ], + ]; + + yield [ + new class {}, + null, + ]; + + yield [ + new \stdClass(), + null, + ]; + + yield [ + [], + null, + ]; + } + + public function testSerializeAsJson(): void + { + $attribute = Attribute::tryFromValue('foo'); + + $this->assertInstanceOf(Attribute::class, $attribute); + + $this->assertEquals( + ['type' => 'string', 'value' => 'foo'], + $attribute->jsonSerialize() + ); + + $this->assertEquals( + '{"type":"string","value":"foo"}', + json_encode($attribute) + ); + } + + public function testSerializeAsArray(): void + { + $attribute = Attribute::tryFromValue('foo'); + + $this->assertInstanceOf(Attribute::class, $attribute); + + $this->assertEquals( + ['type' => 'string', 'value' => 'foo'], + $attribute->toArray() + ); + } + + public function testSerializeAsString(): void + { + $attribute = Attribute::tryFromValue('foo'); + + $this->assertInstanceOf(Attribute::class, $attribute); + + $this->assertEquals( + 'foo (string)', + (string) $attribute + ); + } + + public function testFromValueFactoryMethod(): void + { + $this->expectException(\InvalidArgumentException::class); + + Attribute::fromValue([]); + } +} diff --git a/tests/Logs/LogTest.php b/tests/Logs/LogTest.php new file mode 100644 index 000000000..0b5a844c8 --- /dev/null +++ b/tests/Logs/LogTest.php @@ -0,0 +1,43 @@ +setAttribute('foo', 'bar'); + $log->setAttribute('should-be-missing', ['foo' => 'bar']); + + $this->assertEquals( + [ + 'timestamp' => $timestamp, + 'trace_id' => '123', + 'level' => 'debug', + 'body' => 'foo', + 'attributes' => [ + 'foo' => [ + 'type' => 'string', + 'value' => 'bar', + ], + ], + ], + $log->jsonSerialize() + ); + } +} diff --git a/tests/Logs/LogsTest.php b/tests/Logs/LogsTest.php new file mode 100644 index 000000000..05931b1c2 --- /dev/null +++ b/tests/Logs/LogsTest.php @@ -0,0 +1,154 @@ +createMock(ClientInterface::class); + $client->expects($this->any()) + ->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + 'enable_logs' => false, + ])); + + $client->expects($this->never()) + ->method('captureEvent'); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + logger()->info('Some info message'); + + $this->assertNull(logger()->flush()); + } + + public function testLogSentWhenEnabled(): void + { + $this->assertEvent(function (Event $event) { + $this->assertCount(1, $event->getLogs()); + + $logItem = $event->getLogs()[0]->jsonSerialize(); + + $this->assertEquals(LogLevel::info(), $logItem['level']); + $this->assertEquals('Some info message', $logItem['body']); + }); + + logger()->info('Some info message'); + + $this->assertNotNull(logger()->flush()); + } + + public function testLogWithTemplate(): void + { + $this->assertEvent(function (Event $event) { + $this->assertCount(1, $event->getLogs()); + + $logItem = $event->getLogs()[0]->jsonSerialize(); + + $this->assertEquals(LogLevel::info(), $logItem['level']); + $this->assertEquals('Some info message', $logItem['body']); + }); + + logger()->info('Some %s message', ['info']); + + $this->assertNotNull(logger()->flush()); + } + + public function testLogWithNestedAttributes(): void + { + $this->assertEvent(function (Event $event) { + $this->assertCount(1, $event->getLogs()); + + $logItem = $event->getLogs()[0]->jsonSerialize(); + + $this->assertArrayHasKey('nested.foo', $logItem['attributes']); + $this->assertArrayNotHasKey('nested.should-be-missing', $logItem['attributes']); + + $this->assertEquals('bar', $logItem['attributes']['nested.foo']['value']); + }); + + logger()->info('Some message', [], [ + 'nested' => [ + 'foo' => 'bar', + 'should-be-missing' => [1, 2, 3], + ], + ]); + + $this->assertNotNull(logger()->flush()); + } + + /** + * @dataProvider logLevelDataProvider + */ + public function testLoggerSetsCorrectLevel(LogLevel $level): void + { + $this->assertEvent(function (Event $event) use ($level) { + $this->assertCount(1, $event->getLogs()); + + $this->assertEquals($level, $event->getLogs()[0]->getLevel()); + }); + + logger()->{(string) $level}('Some message'); + + $this->assertNotNull(logger()->flush()); + } + + public static function logLevelDataProvider(): \Generator + { + yield [LogLevel::trace()]; + yield [LogLevel::debug()]; + yield [LogLevel::info()]; + yield [LogLevel::warn()]; + yield [LogLevel::error()]; + yield [LogLevel::fatal()]; + } + + /** + * @param callable(Event): void $assert + */ + private function assertEvent(callable $assert): ClientInterface + { + /** @var TransportInterface&MockObject $transport */ + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once()) + ->method('send') + ->with($this->callback(function (Event $event) use ($assert): bool { + $assert($event); + + return true; + })) + ->willReturnCallback(static function (Event $event): Result { + return new Result(ResultStatus::success(), $event); + }); + + $client = ClientBuilder::create([ + 'enable_logs' => true, + ])->setTransport($transport)->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + return $client; + } +} diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 1e8eca0fe..8cf6d16ec 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -16,6 +16,8 @@ use Sentry\ExceptionDataBag; use Sentry\ExceptionMechanism; use Sentry\Frame; +use Sentry\Logs\Log; +use Sentry\Logs\LogLevel; use Sentry\MonitorConfig; use Sentry\MonitorSchedule; use Sentry\Options; @@ -405,6 +407,21 @@ public static function serializeAsEnvelopeDataProvider(): iterable {"event_id":"fc9442f5aef34234bb22b9a615e30ccd","sent_at":"2020-08-18T22:47:15Z","dsn":"http:\/\/public@example.com\/sentry\/1","sdk":{"name":"sentry.php","version":"$sdkVersion","packages":[{"name":"composer:sentry\/sentry","version":"$sdkVersion"}]}} {"type":"check_in","content_type":"application\/json"} {"check_in_id":"$checkinId","monitor_slug":"my-monitor","status":"ok","duration":10,"release":"1.0.0","environment":"dev","monitor_config":{"schedule":{"type":"crontab","value":"0 0 * * *","unit":""},"checkin_margin":10,"max_runtime":12,"timezone":"Europe\/Amsterdam","failure_issue_threshold":5,"recovery_threshold":10},"contexts":{"trace":{"trace_id":"21160e9b836d479f81611368b2aa3d2c","span_id":"5dd538dc297544cc"}}} +TEXT + , + ]; + + $event = Event::createLogs(new EventId('fc9442f5aef34234bb22b9a615e30ccd')); + $event->setLogs([ + new Log(ClockMock::microtime(true), '21160e9b836d479f81611368b2aa3d2c', LogLevel::info(), 'A log message'), + ]); + + yield [ + $event, + <<assertSame($expectedResult, Arr::simpleDot($value)); + } + + public static function simpleDotDataProvider(): \Generator + { + yield [ + [1, 2, 3], + [1, 2, 3], + ]; + + yield [ + [ + 'key' => 'value', + ], + [ + 'key' => 'value', + ], + ]; + + yield [ + [ + 'key' => [ + 'key2' => 'value', + ], + ], + [ + 'key.key2' => 'value', + ], + ]; + + yield [ + [ + 'key' => ['foo', 'bar'], + ], + [ + 'key' => ['foo', 'bar'], + ], + ]; + + yield [ + [ + 'key' => [ + 'key2' => ['foo', 'bar'], + ], + ], + [ + 'key.key2' => ['foo', 'bar'], + ], + ]; + + $someClass = new \stdClass(); + + yield [ + [ + 'key' => $someClass, + ], + [ + 'key' => $someClass, + ], + ]; + } + + /** + * @dataProvider isListDataProvider + */ + public function testIsList(array $value, bool $expectedResult): void + { + $this->assertSame($expectedResult, Arr::isList($value)); + } + + public static function isListDataProvider(): \Generator + { + yield [ + [1, 2, 3], + true, + ]; + + yield [ + [ + 'key' => 'value', + ], + false, + ]; + } +}