From c88988dbf99a2defaee857f2c15669cb5b79cb01 Mon Sep 17 00:00:00 2001 From: Michi Hoffmann Date: Thu, 21 Dec 2023 17:01:18 +0100 Subject: [PATCH] Add support for Sentry Developer Metrics (#1619) Co-authored-by: Alex Bouma --- phpstan-baseline.neon | 10 + src/Event.php | 29 ++ src/EventType.php | 5 + .../FrameContextifierIntegration.php | 32 +- src/Metrics/Metrics.php | 135 +++++++++ src/Metrics/MetricsAggregator.php | 153 ++++++++++ src/Metrics/MetricsUnit.php | 170 +++++++++++ src/Metrics/Types/AbstractType.php | 129 ++++++++ src/Metrics/Types/CounterType.php | 51 ++++ src/Metrics/Types/DistributionType.php | 51 ++++ src/Metrics/Types/GaugeType.php | 92 ++++++ src/Metrics/Types/SetType.php | 57 ++++ src/Options.php | 22 ++ src/Serializer/EnvelopItems/EventItem.php | 58 +--- src/Serializer/EnvelopItems/MetricsItem.php | 110 +++++++ .../EnvelopItems/TransactionItem.php | 4 + src/Serializer/PayloadSerializer.php | 4 + .../Traits/StacktraceFrameSeralizerTrait.php | 68 +++++ src/Tracing/Span.php | 69 +++++ src/functions.php | 6 + tests/Metrics/MetricsTest.php | 284 ++++++++++++++++++ tests/OptionsTest.php | 7 + tests/Serializer/PayloadSerializerTest.php | 32 ++ tests/bootstrap.php | 2 + 24 files changed, 1519 insertions(+), 61 deletions(-) create mode 100644 src/Metrics/Metrics.php create mode 100644 src/Metrics/MetricsAggregator.php create mode 100644 src/Metrics/MetricsUnit.php create mode 100644 src/Metrics/Types/AbstractType.php create mode 100644 src/Metrics/Types/CounterType.php create mode 100644 src/Metrics/Types/DistributionType.php create mode 100644 src/Metrics/Types/GaugeType.php create mode 100644 src/Metrics/Types/SetType.php create mode 100644 src/Serializer/EnvelopItems/MetricsItem.php create mode 100644 src/Serializer/Traits/StacktraceFrameSeralizerTrait.php create mode 100644 tests/Metrics/MetricsTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7f0676d7b..906166b06 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -40,6 +40,11 @@ parameters: count: 1 path: src/Logger/DebugStdOutLogger.php + - + message: "#^Method Sentry\\\\Metrics\\\\Types\\\\AbstractType\\:\\:add\\(\\) has parameter \\$value with no type specified\\.$#" + count: 1 + path: src/Metrics/Types/AbstractType.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\\.$#" count: 1 @@ -230,6 +235,11 @@ parameters: count: 1 path: src/Options.php + - + message: "#^Method Sentry\\\\Options\\:\\:shouldAttachMetricCodeLocations\\(\\) should return bool but returns mixed\\.$#" + count: 1 + path: src/Options.php + - message: "#^Method Sentry\\\\Options\\:\\:shouldAttachStacktrace\\(\\) should return bool but returns mixed\\.$#" count: 1 diff --git a/src/Event.php b/src/Event.php index b9fd06e27..4a77b948e 100644 --- a/src/Event.php +++ b/src/Event.php @@ -6,6 +6,7 @@ use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; +use Sentry\Metrics\Types\AbstractType; use Sentry\Profiling\Profile; use Sentry\Tracing\Span; @@ -55,6 +56,11 @@ final class Event */ private $checkIn; + /** + * @var array The metrics data + */ + private $metrics = []; + /** * @var string|null The name of the server (e.g. the host name) */ @@ -210,6 +216,11 @@ public static function createCheckIn(?EventId $eventId = null): self return new self($eventId, EventType::checkIn()); } + public static function createMetrics(?EventId $eventId = null): self + { + return new self($eventId, EventType::metrics()); + } + /** * Gets the ID of this event. */ @@ -354,6 +365,24 @@ public function setCheckIn(?CheckIn $checkIn): self return $this; } + /** + * @return array + */ + public function getMetrics(): array + { + return $this->metrics; + } + + /** + * @param array $metrics + */ + public function setMetrics(array $metrics): self + { + $this->metrics = $metrics; + + return $this; + } + /** * Gets the name of the server. */ diff --git a/src/EventType.php b/src/EventType.php index beb04930f..208789e3a 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 metrics(): self + { + return self::getInstance('metrics'); + } + public function __toString(): string { return $this->value; diff --git a/src/Integration/FrameContextifierIntegration.php b/src/Integration/FrameContextifierIntegration.php index d0ed04f99..178f04fd4 100644 --- a/src/Integration/FrameContextifierIntegration.php +++ b/src/Integration/FrameContextifierIntegration.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Sentry\Event; +use Sentry\Frame; use Sentry\SentrySdk; use Sentry\Stacktrace; use Sentry\State\Scope; @@ -65,6 +66,13 @@ public function setupOnce(): void } } + foreach ($event->getMetrics() as $metric) { + if ($metric->hasCodeLocation()) { + $frame = $metric->getCodeLocation(); + $integration->addContextToStacktraceFrame($maxContextLines, $frame); + } + } + return $event; }); } @@ -78,16 +86,30 @@ public function setupOnce(): void private function addContextToStacktraceFrames(int $maxContextLines, Stacktrace $stacktrace): void { foreach ($stacktrace->getFrames() as $frame) { - if ($frame->isInternal() || $frame->getAbsoluteFilePath() === null) { + if ($frame->isInternal()) { continue; } - $sourceCodeExcerpt = $this->getSourceCodeExcerpt($maxContextLines, $frame->getAbsoluteFilePath(), $frame->getLine()); + $this->addContextToStacktraceFrame($maxContextLines, $frame); + } + } - $frame->setPreContext($sourceCodeExcerpt['pre_context']); - $frame->setContextLine($sourceCodeExcerpt['context_line']); - $frame->setPostContext($sourceCodeExcerpt['post_context']); + /** + * Contextifies the given frame. + * + * @param int $maxContextLines The maximum number of lines of code to read + */ + private function addContextToStacktraceFrame(int $maxContextLines, Frame $frame): void + { + if ($frame->getAbsoluteFilePath() === null) { + return; } + + $sourceCodeExcerpt = $this->getSourceCodeExcerpt($maxContextLines, $frame->getAbsoluteFilePath(), $frame->getLine()); + + $frame->setPreContext($sourceCodeExcerpt['pre_context']); + $frame->setContextLine($sourceCodeExcerpt['context_line']); + $frame->setPostContext($sourceCodeExcerpt['post_context']); } /** diff --git a/src/Metrics/Metrics.php b/src/Metrics/Metrics.php new file mode 100644 index 000000000..1b4b35937 --- /dev/null +++ b/src/Metrics/Metrics.php @@ -0,0 +1,135 @@ +aggregator = new MetricsAggregator(); + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * @param int|float $value + * @param string[] $tags + */ + public function increment( + string $key, + $value, + ?MetricsUnit $unit = null, + array $tags = [], + ?int $timestamp = null, + int $stackLevel = 0 + ): void { + $this->aggregator->add( + CounterType::TYPE, + $key, + $value, + $unit, + $tags, + $timestamp, + $stackLevel + ); + } + + /** + * @param int|float $value + * @param string[] $tags + */ + public function distribution( + string $key, + $value, + ?MetricsUnit $unit = null, + array $tags = [], + ?int $timestamp = null, + int $stackLevel = 0 + ): void { + $this->aggregator->add( + DistributionType::TYPE, + $key, + $value, + $unit, + $tags, + $timestamp, + $stackLevel + ); + } + + /** + * @param int|float $value + * @param string[] $tags + */ + public function gauge( + string $key, + $value, + ?MetricsUnit $unit = null, + array $tags = [], + ?int $timestamp = null, + int $stackLevel = 0 + ): void { + $this->aggregator->add( + GaugeType::TYPE, + $key, + $value, + $unit, + $tags, + $timestamp, + $stackLevel + ); + } + + /** + * @param int|string $value + * @param string[] $tags + */ + public function set( + string $key, + $value, + ?MetricsUnit $unit = null, + array $tags = [], + ?int $timestamp = null, + int $stackLevel = 0 + ): void { + $this->aggregator->add( + SetType::TYPE, + $key, + $value, + $unit, + $tags, + $timestamp, + $stackLevel + ); + } + + public function flush(): ?EventId + { + return $this->aggregator->flush(); + } +} diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php new file mode 100644 index 000000000..8cb50f036 --- /dev/null +++ b/src/Metrics/MetricsAggregator.php @@ -0,0 +1,153 @@ + + */ + private $buckets = []; + + private const METRIC_TYPES = [ + CounterType::TYPE => CounterType::class, + DistributionType::TYPE => DistributionType::class, + GaugeType::TYPE => GaugeType::class, + SetType::TYPE => SetType::class, + ]; + + /** + * @param string[] $tags + * @param int|float|string $value + */ + public function add( + string $type, + string $key, + $value, + ?MetricsUnit $unit, + array $tags, + ?int $timestamp, + int $stackLevel + ): void { + if ($timestamp === null) { + $timestamp = time(); + } + if ($unit === null) { + $unit = MetricsUnit::none(); + } + + $tags = $this->serializeTags($tags); + + $bucketTimestamp = floor($timestamp / self::ROLLUP_IN_SECONDS); + $bucketKey = md5( + $type . + $key . + $unit . + implode('', $tags) . + $bucketTimestamp + ); + + if (\array_key_exists($bucketKey, $this->buckets)) { + $metric = $this->buckets[$bucketKey]; + $metric->add($value); + } else { + $metricTypeClass = self::METRIC_TYPES[$type]; + /** @var AbstractType $metric */ + /** @phpstan-ignore-next-line SetType accepts int|float|string, others only int|float */ + $metric = new $metricTypeClass($key, $value, $unit, $tags, $timestamp); + $this->buckets[$bucketKey] = $metric; + } + + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + if ($client !== null) { + $options = $client->getOptions(); + + if ( + $options->shouldAttachMetricCodeLocations() + && !$metric->hasCodeLocation() + ) { + $metric->addCodeLocation($stackLevel); + } + } + + $span = $hub->getSpan(); + if ($span !== null) { + $span->setMetricsSummary($type, $key, $value, $unit, $tags); + } + } + + public function flush(): ?EventId + { + $hub = SentrySdk::getCurrentHub(); + $event = Event::createMetrics()->setMetrics($this->buckets); + + $this->buckets = []; + + return $hub->captureEvent($event); + } + + /** + * @param string[] $tags + * + * @return string[] + */ + private function serializeTags(array $tags): array + { + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + if ($client !== null) { + $options = $client->getOptions(); + + $defaultTags = [ + 'environment' => $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT, + ]; + + $release = $options->getRelease(); + if ($release !== null) { + $defaultTags['release'] = $release; + } + + $hub->configureScope(function (Scope $scope) use (&$defaultTags) { + $transaction = $scope->getTransaction(); + if ( + $transaction !== null + // Only include the transaction name if it has good quality + && $transaction->getMetadata()->getSource() !== TransactionSource::url() + ) { + $defaultTags['transaction'] = $transaction->getName(); + } + }); + + $tags = array_merge($defaultTags, $tags); + } + + // It's very important to sort the tags in order to obtain the same bucket key. + ksort($tags); + + return $tags; + } +} diff --git a/src/Metrics/MetricsUnit.php b/src/Metrics/MetricsUnit.php new file mode 100644 index 000000000..f417a5599 --- /dev/null +++ b/src/Metrics/MetricsUnit.php @@ -0,0 +1,170 @@ + A list of cached enum instances + */ + private static $instances = []; + + private function __construct(string $value) + { + $this->value = $value; + } + + public static function nanosecond(): self + { + return self::getInstance('nanosecond'); + } + + public static function microsecond(): self + { + return self::getInstance('microsecond'); + } + + public static function millisecond(): self + { + return self::getInstance('millisecond'); + } + + public static function second(): self + { + return self::getInstance('second'); + } + + public static function minute(): self + { + return self::getInstance('minute'); + } + + public static function hour(): self + { + return self::getInstance('hour'); + } + + public static function day(): self + { + return self::getInstance('day'); + } + + public static function week(): self + { + return self::getInstance('week'); + } + + public static function bit(): self + { + return self::getInstance('bit'); + } + + public static function byte(): self + { + return self::getInstance('byte'); + } + + public static function kilobyte(): self + { + return self::getInstance('kilobyte'); + } + + public static function kibibyte(): self + { + return self::getInstance('kibibyte'); + } + + public static function megabyte(): self + { + return self::getInstance('megabyte'); + } + + public static function mebibyte(): self + { + return self::getInstance('mebibyte'); + } + + public static function gigabyte(): self + { + return self::getInstance('gigabyte'); + } + + public static function gibibyte(): self + { + return self::getInstance('gibibyte'); + } + + public static function terabyte(): self + { + return self::getInstance('terabyte'); + } + + public static function tebibyte(): self + { + return self::getInstance('tebibyte'); + } + + public static function petabyte(): self + { + return self::getInstance('petabyte'); + } + + public static function pebibyte(): self + { + return self::getInstance('pebibyte'); + } + + public static function exabyte(): self + { + return self::getInstance('exabyte'); + } + + public static function exbibyte(): self + { + return self::getInstance('exbibyte'); + } + + public static function ratio(): self + { + return self::getInstance('ratio'); + } + + public static function percent(): self + { + return self::getInstance('percent'); + } + + public static function none(): self + { + return self::getInstance('none'); + } + + public static function custom(string $unit): self + { + return new self($unit); + } + + 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/Metrics/Types/AbstractType.php b/src/Metrics/Types/AbstractType.php new file mode 100644 index 000000000..7a089b8c6 --- /dev/null +++ b/src/Metrics/Types/AbstractType.php @@ -0,0 +1,129 @@ +key = $key; + $this->unit = $unit; + $this->tags = $tags; + $this->timestamp = $timestamp; + } + + abstract public function add($value): void; + + /** + * @return array + */ + abstract public function serialize(): array; + + abstract public function getType(): string; + + public function getKey(): string + { + return $this->key; + } + + public function getUnit(): MetricsUnit + { + return $this->unit; + } + + /** + * @return string[] + */ + public function getTags(): array + { + return $this->tags; + } + + public function getTimestamp(): int + { + return $this->timestamp; + } + + /** + * @phpstan-assert-if-true !null $this->getCodeLocation() + */ + public function hasCodeLocation(): bool + { + return $this->codeLocation !== null; + } + + public function getCodeLocation(): ?Frame + { + return $this->codeLocation; + } + + public function addCodeLocation(int $stackLevel): void + { + $client = SentrySdk::getCurrentHub()->getClient(); + if ($client === null) { + return; + } + + $options = $client->getOptions(); + + $backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 3 + $stackLevel); + $frame = end($backtrace); + + // If we don't have a valid frame there is no code location to resolve + if ($frame === false || empty($frame['file']) || empty($frame['line'])) { + return; + } + + $frameBuilder = new FrameBuilder($options, new RepresentationSerializer($options)); + $this->codeLocation = $frameBuilder->buildFromBacktraceFrame($frame['file'], $frame['line'], $frame); + } + + public function getMri(): string + { + return sprintf( + '%s:%s@%s', + $this->getType(), + $this->getKey(), + (string) $this->getUnit() + ); + } +} diff --git a/src/Metrics/Types/CounterType.php b/src/Metrics/Types/CounterType.php new file mode 100644 index 000000000..6f54b3f9c --- /dev/null +++ b/src/Metrics/Types/CounterType.php @@ -0,0 +1,51 @@ +value = (float) $value; + } + + /** + * @param int|float $value + */ + public function add($value): void + { + $this->value += (float) $value; + } + + public function serialize(): array + { + return [$this->value]; + } + + public function getType(): string + { + return self::TYPE; + } +} diff --git a/src/Metrics/Types/DistributionType.php b/src/Metrics/Types/DistributionType.php new file mode 100644 index 000000000..fa43e4f48 --- /dev/null +++ b/src/Metrics/Types/DistributionType.php @@ -0,0 +1,51 @@ + + */ + private $values; + + /** + * @param int|float $value + */ + public function __construct(string $key, $value, MetricsUnit $unit, array $tags, int $timestamp) + { + parent::__construct($key, $unit, $tags, $timestamp); + + $this->add($value); + } + + /** + * @param int|float $value + */ + public function add($value): void + { + $this->values[] = (float) $value; + } + + public function serialize(): array + { + return $this->values; + } + + public function getType(): string + { + return self::TYPE; + } +} diff --git a/src/Metrics/Types/GaugeType.php b/src/Metrics/Types/GaugeType.php new file mode 100644 index 000000000..1803365a9 --- /dev/null +++ b/src/Metrics/Types/GaugeType.php @@ -0,0 +1,92 @@ +last = $value; + $this->min = $value; + $this->max = $value; + $this->sum = $value; + $this->count = 1; + } + + /** + * @param int|float $value + */ + public function add($value): void + { + $value = (float) $value; + + $this->last = $value; + $this->min = min($this->min, $value); + $this->max = max($this->min, $value); + $this->sum += $value; + ++$this->count; + } + + /** + * @return array + */ + public function serialize(): array + { + return [ + $this->last, + $this->min, + $this->max, + $this->sum, + $this->count, + ]; + } + + public function getType(): string + { + return self::TYPE; + } +} diff --git a/src/Metrics/Types/SetType.php b/src/Metrics/Types/SetType.php new file mode 100644 index 000000000..d761bc264 --- /dev/null +++ b/src/Metrics/Types/SetType.php @@ -0,0 +1,57 @@ + + */ + private $values; + + /** + * @param int|string $value + */ + public function __construct(string $key, $value, MetricsUnit $unit, array $tags, int $timestamp) + { + parent::__construct($key, $unit, $tags, $timestamp); + + $this->add($value); + } + + /** + * @param int|string $value + */ + public function add($value): void + { + $this->values[] = $value; + } + + public function serialize(): array + { + foreach ($this->values as $key => $value) { + if (\is_string($value)) { + $this->values[$key] = crc32($value); + } + } + + return $this->values; + } + + public function getType(): string + { + return self::TYPE; + } +} diff --git a/src/Options.php b/src/Options.php index 546b05b67..79b71fc8b 100644 --- a/src/Options.php +++ b/src/Options.php @@ -217,6 +217,26 @@ public function setAttachStacktrace(bool $enable): self return $this; } + /** + * Gets whether a metric has their code location attached. + */ + public function shouldAttachMetricCodeLocations(): bool + { + return $this->options['attach_metric_code_locations']; + } + + /** + * Sets whether a metric will have their code location attached. + */ + public function setAttachMetricCodeLocations(bool $enable): self + { + $options = array_merge($this->options, ['attach_metric_code_locations' => $enable]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Gets the number of lines of code context to capture, or null if none. */ @@ -1009,6 +1029,7 @@ private function configureOptions(OptionsResolver $resolver): void 'traces_sampler' => null, 'profiles_sample_rate' => null, 'attach_stacktrace' => false, + 'attach_metric_code_locations' => false, 'context_lines' => 5, 'environment' => $_SERVER['SENTRY_ENVIRONMENT'] ?? null, 'logger' => null, @@ -1056,6 +1077,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); $resolver->setAllowedTypes('attach_stacktrace', 'bool'); + $resolver->setAllowedTypes('attach_metric_code_locations', 'bool'); $resolver->setAllowedTypes('context_lines', ['null', 'int']); $resolver->setAllowedTypes('environment', ['null', 'string']); $resolver->setAllowedTypes('in_app_exclude', 'string[]'); diff --git a/src/Serializer/EnvelopItems/EventItem.php b/src/Serializer/EnvelopItems/EventItem.php index f4fb825e7..a9b0fdad6 100644 --- a/src/Serializer/EnvelopItems/EventItem.php +++ b/src/Serializer/EnvelopItems/EventItem.php @@ -6,8 +6,8 @@ use Sentry\Event; use Sentry\ExceptionDataBag; -use Sentry\Frame; use Sentry\Serializer\Traits\BreadcrumbSeralizerTrait; +use Sentry\Serializer\Traits\StacktraceFrameSeralizerTrait; use Sentry\Util\JSON; /** @@ -16,6 +16,7 @@ class EventItem implements EnvelopeItemInterface { use BreadcrumbSeralizerTrait; + use StacktraceFrameSeralizerTrait; public static function toEnvelopeItem(Event $event): string { @@ -189,59 +190,4 @@ protected static function serializeException(ExceptionDataBag $exception): array return $result; } - - /** - * @return array - * - * @psalm-return array{ - * filename: string, - * lineno: int, - * in_app: bool, - * abs_path?: string, - * function?: string, - * raw_function?: string, - * pre_context?: string[], - * context_line?: string, - * post_context?: string[], - * vars?: array - * } - */ - protected static function serializeStacktraceFrame(Frame $frame): array - { - $result = [ - 'filename' => $frame->getFile(), - 'lineno' => $frame->getLine(), - 'in_app' => $frame->isInApp(), - ]; - - if ($frame->getAbsoluteFilePath() !== null) { - $result['abs_path'] = $frame->getAbsoluteFilePath(); - } - - if ($frame->getFunctionName() !== null) { - $result['function'] = $frame->getFunctionName(); - } - - if ($frame->getRawFunctionName() !== null) { - $result['raw_function'] = $frame->getRawFunctionName(); - } - - if (!empty($frame->getPreContext())) { - $result['pre_context'] = $frame->getPreContext(); - } - - if ($frame->getContextLine() !== null) { - $result['context_line'] = $frame->getContextLine(); - } - - if (!empty($frame->getPostContext())) { - $result['post_context'] = $frame->getPostContext(); - } - - if (!empty($frame->getVars())) { - $result['vars'] = $frame->getVars(); - } - - return $result; - } } diff --git a/src/Serializer/EnvelopItems/MetricsItem.php b/src/Serializer/EnvelopItems/MetricsItem.php new file mode 100644 index 000000000..3bd0b7c3a --- /dev/null +++ b/src/Serializer/EnvelopItems/MetricsItem.php @@ -0,0 +1,110 @@ +getMetrics(); + if (empty($metrics)) { + return ''; + } + + $statsdPayload = []; + $metricMetaPayload = []; + + foreach ($metrics as $metric) { + // key - my.metric + $line = preg_replace(self::KEY_PATTERN, '_', $metric->getKey()); + + if ($metric->getUnit() !== MetricsUnit::none()) { + // unit - @second + $line .= '@' . $metric->getunit(); + } + + foreach ($metric->serialize() as $value) { + // value - 2:3:4... + $line .= ':' . $value; + } + + // type - |c|, |d|, ... + $line .= '|' . $metric->getType() . '|'; + + $tags = ''; + foreach ($metric->getTags() as $key => $value) { + $tags .= preg_replace(self::KEY_PATTERN, '_', $key) . + ':' . preg_replace(self::VALUE_PATTERN, '', $value); + } + + // tags - #key:value,key:value... + $line .= '#' . $tags . '|'; + // timestamp - T123456789 + $line .= 'T' . $metric->getTimestamp(); + + $statsdPayload[] = $line; + + if ($metric->hasCodeLocation()) { + $metricMetaPayload[$metric->getMri()][] = array_merge( + ['type' => 'location'], + self::serializeStacktraceFrame($metric->getCodeLocation()) + ); + } + } + + $statsdPayload = implode("\n", $statsdPayload); + + $statsdHeader = [ + 'type' => 'statsd', + 'length' => mb_strlen($statsdPayload), + ]; + + if (!empty($metricMetaPayload)) { + $metricMetaPayload = JSON::encode([ + 'timestamp' => time(), + 'mapping' => $metricMetaPayload, + ]); + + $metricMetaHeader = [ + 'type' => 'metric_meta', + 'length' => mb_strlen($metricMetaPayload), + ]; + + return sprintf( + "%s\n%s\n%s\n%s", + JSON::encode($statsdHeader), + $statsdPayload, + JSON::encode($metricMetaHeader), + $metricMetaPayload + ); + } + + return sprintf( + "%s\n%s", + JSON::encode($statsdHeader), + $statsdPayload + ); + } +} diff --git a/src/Serializer/EnvelopItems/TransactionItem.php b/src/Serializer/EnvelopItems/TransactionItem.php index f44dc38d0..5296299be 100644 --- a/src/Serializer/EnvelopItems/TransactionItem.php +++ b/src/Serializer/EnvelopItems/TransactionItem.php @@ -176,6 +176,10 @@ protected static function serializeSpan(Span $span): array $result['tags'] = $span->getTags(); } + if (!empty($span->getMetricsSummary())) { + $result['metrics_summary'] = $span->getMetricsSummary(); + } + return $result; } } diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index ad1365b3a..e531a9a0e 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\MetricsItem; use Sentry\Serializer\EnvelopItems\ProfileItem; use Sentry\Serializer\EnvelopItems\TransactionItem; use Sentry\Tracing\DynamicSamplingContext; @@ -77,6 +78,9 @@ public function serialize(Event $event): string case EventType::checkIn(): $items = CheckInItem::toEnvelopeItem($event); break; + case EventType::metrics(): + $items = MetricsItem::toEnvelopeItem($event); + break; } return sprintf("%s\n%s", JSON::encode($envelopeHeader), $items); diff --git a/src/Serializer/Traits/StacktraceFrameSeralizerTrait.php b/src/Serializer/Traits/StacktraceFrameSeralizerTrait.php new file mode 100644 index 000000000..32e777f7b --- /dev/null +++ b/src/Serializer/Traits/StacktraceFrameSeralizerTrait.php @@ -0,0 +1,68 @@ + + * + * @psalm-return array{ + * filename: string, + * lineno: int, + * in_app: bool, + * abs_path?: string, + * function?: string, + * raw_function?: string, + * pre_context?: string[], + * context_line?: string, + * post_context?: string[], + * vars?: array + * } + */ + protected static function serializeStacktraceFrame(Frame $frame): array + { + $result = [ + 'filename' => $frame->getFile(), + 'lineno' => $frame->getLine(), + 'in_app' => $frame->isInApp(), + ]; + + if ($frame->getAbsoluteFilePath() !== null) { + $result['abs_path'] = $frame->getAbsoluteFilePath(); + } + + if ($frame->getFunctionName() !== null) { + $result['function'] = $frame->getFunctionName(); + } + + if ($frame->getRawFunctionName() !== null) { + $result['raw_function'] = $frame->getRawFunctionName(); + } + + if (!empty($frame->getPreContext())) { + $result['pre_context'] = $frame->getPreContext(); + } + + if ($frame->getContextLine() !== null) { + $result['context_line'] = $frame->getContextLine(); + } + + if (!empty($frame->getPostContext())) { + $result['post_context'] = $frame->getPostContext(); + } + + if (!empty($frame->getVars())) { + $result['vars'] = $frame->getVars(); + } + + return $result; + } +} diff --git a/src/Tracing/Span.php b/src/Tracing/Span.php index d8d1859ca..08f0b771d 100644 --- a/src/Tracing/Span.php +++ b/src/Tracing/Span.php @@ -5,11 +5,21 @@ namespace Sentry\Tracing; use Sentry\EventId; +use Sentry\Metrics\MetricsUnit; +use Sentry\Metrics\Types\SetType; use Sentry\SentrySdk; use Sentry\State\Scope; /** * This class stores all the information about a span. + * + * @phpstan-type MetricsSummary array{ + * min: int|float, + * max: int|float, + * sum: int|float, + * count: int, + * tags: array, + * } */ class Span { @@ -78,6 +88,11 @@ class Span */ protected $transaction; + /** + * @var array + */ + protected $metricsSummary = []; + /** * Constructor. * @@ -476,6 +491,60 @@ public function detachSpanRecorder() return $this; } + /** + * @return array + */ + public function getMetricsSummary(): array + { + return $this->metricsSummary; + } + + /** + * @param string|int|float $value + * @param string[] $tags + */ + public function setMetricsSummary( + string $type, + string $key, + $value, + MetricsUnit $unit, + array $tags + ): void { + $mri = sprintf('%s:%s@%s', $type, $key, (string) $unit); + $bucketKey = $mri . implode('', $tags); + + if (\array_key_exists($bucketKey, $this->metricsSummary)) { + if ($type === SetType::TYPE) { + $value = 1.0; + } else { + $value = (float) $value; + } + + $summary = $this->metricsSummary[$bucketKey]; + $this->metricsSummary[$bucketKey] = [ + 'min' => min($summary['min'], $value), + 'max' => max($summary['max'], $value), + 'sum' => $summary['sum'] + $value, + 'count' => $summary['count'] + 1, + 'tags' => $tags, + ]; + } else { + if ($type === SetType::TYPE) { + $value = 0.0; + } else { + $value = (float) $value; + } + + $this->metricsSummary[$bucketKey] = [ + 'min' => $value, + 'max' => $value, + 'sum' => $value, + 'count' => 1, + 'tags' => $tags, + ]; + } + } + /** * Returns the transaction containing this span. */ diff --git a/src/functions.php b/src/functions.php index 9dba70ab1..465ad95eb 100644 --- a/src/functions.php +++ b/src/functions.php @@ -4,6 +4,7 @@ namespace Sentry; +use Sentry\Metrics\Metrics; use Sentry\State\Scope; use Sentry\Tracing\PropagationContext; use Sentry\Tracing\SpanContext; @@ -268,3 +269,8 @@ function continueTrace(string $sentryTrace, string $baggage): TransactionContext return TransactionContext::fromHeaders($sentryTrace, $baggage); } + +function metrics(): Metrics +{ + return Metrics::getInstance(); +} diff --git a/tests/Metrics/MetricsTest.php b/tests/Metrics/MetricsTest.php new file mode 100644 index 000000000..87b26e4bc --- /dev/null +++ b/tests/Metrics/MetricsTest.php @@ -0,0 +1,284 @@ +createMock(ClientInterface::class); + $client->expects($this->any()) + ->method('getOptions') + ->willReturn(new Options([ + 'release' => '1.0.0', + 'environment' => 'development', + 'attach_metric_code_locations' => true, + ])); + + $self = $this; + + $client->expects($this->once()) + ->method('captureEvent') + ->with($this->callback(static function (Event $event) use ($self): bool { + $metric = $event->getMetrics()['92ed00fdaf9543ff4cace691f8a5166b']; + + $self->assertSame(CounterType::TYPE, $metric->getType()); + $self->assertSame('foo', $metric->getKey()); + $self->assertSame([3.0], $metric->serialize()); + $self->assertSame(MetricsUnit::second(), $metric->getUnit()); + $self->assertSame( + [ + 'environment' => 'development', + 'foo' => 'bar', + 'release' => '1.0.0', + ], + $metric->getTags() + ); + $self->assertSame(1699412953, $metric->getTimestamp()); + + $codeLocation = $metric->getCodeLocation(); + + $self->assertSame('Sentry\Metrics\Metrics::increment', $codeLocation->getFunctionName()); + + return true; + })); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + metrics()->increment( + 'foo', + 1, + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->increment( + 'foo', + 2, + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->flush(); + } + + public function testDistribution(): void + { + ClockMock::withClockMock(1699412953); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->any()) + ->method('getOptions') + ->willReturn(new Options([ + 'release' => '1.0.0', + 'environment' => 'development', + 'attach_metric_code_locations' => true, + ])); + + $self = $this; + + $client->expects($this->once()) + ->method('captureEvent') + ->with($this->callback(static function (Event $event) use ($self): bool { + $metric = $event->getMetrics()['8a817dcdb12cfffc1fa8b459ad0c9d56']; + + $self->assertSame(DistributionType::TYPE, $metric->getType()); + $self->assertSame('foo', $metric->getKey()); + $self->assertSame([1.0, 2.0], $metric->serialize()); + $self->assertSame(MetricsUnit::second(), $metric->getUnit()); + $self->assertSame( + [ + 'environment' => 'development', + 'foo' => 'bar', + 'release' => '1.0.0', + ], + $metric->getTags() + ); + $self->assertSame(1699412953, $metric->getTimestamp()); + + $codeLocation = $metric->getCodeLocation(); + + $self->assertSame('Sentry\Metrics\Metrics::distribution', $codeLocation->getFunctionName()); + + return true; + })); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + metrics()->distribution( + 'foo', + 1, + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->distribution( + 'foo', + 2, + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->flush(); + } + + public function testGauge(): void + { + ClockMock::withClockMock(1699412953); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->any()) + ->method('getOptions') + ->willReturn(new Options([ + 'release' => '1.0.0', + 'environment' => 'development', + 'attach_metric_code_locations' => true, + ])); + + $self = $this; + + $client->expects($this->once()) + ->method('captureEvent') + ->with($this->callback(static function (Event $event) use ($self): bool { + $metric = $event->getMetrics()['d2a09273b9c61b66a0e6ee79c1babfed']; + + $self->assertSame(GaugeType::TYPE, $metric->getType()); + $self->assertSame('foo', $metric->getKey()); + $self->assertSame([ + 2.0, // last + 1.0, // min + 2.0, // max + 3.0, // sum, + 2, // count, + ], $metric->serialize()); + $self->assertSame(MetricsUnit::second(), $metric->getUnit()); + $self->assertSame( + [ + 'environment' => 'development', + 'foo' => 'bar', + 'release' => '1.0.0', + ], + $metric->getTags() + ); + $self->assertSame(1699412953, $metric->getTimestamp()); + + $codeLocation = $metric->getCodeLocation(); + + $self->assertSame('Sentry\Metrics\Metrics::gauge', $codeLocation->getFunctionName()); + + return true; + })); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + metrics()->gauge( + 'foo', + 1, + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->gauge( + 'foo', + 2, + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->flush(); + } + + public function testSet(): void + { + ClockMock::withClockMock(1699412953); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->any()) + ->method('getOptions') + ->willReturn(new Options([ + 'release' => '1.0.0', + 'environment' => 'development', + 'attach_metric_code_locations' => true, + ])); + + $self = $this; + + $client->expects($this->once()) + ->method('captureEvent') + ->with($this->callback(static function (Event $event) use ($self): bool { + $metric = $event->getMetrics()['c900a5750d0bc79016c29a7f0bdcd937']; + + $self->assertSame(SetType::TYPE, $metric->getType()); + $self->assertSame('foo', $metric->getKey()); + $self->assertSame([1, 1, 2356372769], $metric->serialize()); + $self->assertSame(MetricsUnit::second(), $metric->getUnit()); + $self->assertSame( + [ + 'environment' => 'development', + 'foo' => 'bar', + 'release' => '1.0.0', + ], + $metric->getTags() + ); + $self->assertSame(1699412953, $metric->getTimestamp()); + + $codeLocation = $metric->getCodeLocation(); + + $self->assertSame('Sentry\Metrics\Metrics::set', $codeLocation->getFunctionName()); + + return true; + })); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + metrics()->set( + 'foo', + 1, + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->set( + 'foo', + 1, + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->set( + 'foo', + 'foo', + MetricsUnit::second(), + ['foo' => 'bar'] + ); + + metrics()->flush(); + } +} diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 454a69324..1df9f0555 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -131,6 +131,13 @@ static function (): void {}, 'setAttachStacktrace', ]; + yield [ + 'attach_metric_code_locations', + false, + 'shouldAttachMetricCodeLocations', + 'setAttachMetricCodeLocations', + ]; + yield [ 'context_lines', 3, diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 9c294fa72..2819b941f 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -16,6 +16,11 @@ use Sentry\ExceptionDataBag; use Sentry\ExceptionMechanism; use Sentry\Frame; +use Sentry\Metrics\MetricsUnit; +use Sentry\Metrics\Types\CounterType; +use Sentry\Metrics\Types\DistributionType; +use Sentry\Metrics\Types\GaugeType; +use Sentry\Metrics\Types\SetType; use Sentry\MonitorConfig; use Sentry\MonitorSchedule; use Sentry\Options; @@ -402,6 +407,33 @@ 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"}} {"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"},"contexts":{"trace":{"trace_id":"21160e9b836d479f81611368b2aa3d2c","span_id":"5dd538dc297544cc"}}} +TEXT + , + false, + ]; + + $counter = new CounterType('counter', 1.0, MetricsUnit::second(), ['foo' => 'bar'], 1597790835); + $distribution = new DistributionType('distribution', 1.0, MetricsUnit::second(), ['$foo$' => '%bar%'], 1597790835); + $gauge = new GaugeType('gauge', 1.0, MetricsUnit::second(), ['föö' => 'bär'], 1597790835); + $set = new SetType('set', 1.0, MetricsUnit::second(), ['%{key}' => '$value$'], 1597790835); + + $event = Event::createMetrics(new EventId('fc9442f5aef34234bb22b9a615e30ccd')); + $event->setMetrics([ + $counter, + $distribution, + $gauge, + $set, + ]); + + yield [ + $event, + <<