diff --git a/src/Event.php b/src/Event.php index 1ac6cc6af..80d4c2125 100644 --- a/src/Event.php +++ b/src/Event.php @@ -7,6 +7,7 @@ use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Logs\Log; +use Sentry\Profiles\ProfileChunk; use Sentry\Profiling\Profile; use Sentry\Tracing\Span; @@ -71,6 +72,11 @@ final class Event */ private $logs = []; + /** + * @var ProfileChunk|null + */ + private $profileChunk; + /** * @var string|null The name of the server (e.g. the host name) */ @@ -241,6 +247,11 @@ public static function createLogs(?EventId $eventId = null): self return new self($eventId, EventType::logs()); } + public static function createProfileChunk(?EventId $eventId = null): self + { + return new self($eventId, EventType::profileChunk()); + } + /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ @@ -445,6 +456,18 @@ public function setLogs(array $logs): self return $this; } + public function getProfileChunk(): ?ProfileChunk + { + return $this->profileChunk; + } + + public function setProfileChunk(?ProfileChunk $profileChunk): self + { + $this->profileChunk = $profileChunk; + + 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 3c2d13fb3..822e62eb8 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -47,6 +47,11 @@ public static function logs(): self return self::getInstance('log'); } + public static function profileChunk(): self + { + return self::getInstance('profile_chunk'); + } + /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ diff --git a/src/Options.php b/src/Options.php index ea5e04787..13167902e 100644 --- a/src/Options.php +++ b/src/Options.php @@ -194,10 +194,7 @@ public function setTracesSampleRate(?float $sampleRate): self public function getProfilesSampleRate(): ?float { - /** @var int|float|null $value */ - $value = $this->options['profiles_sample_rate'] ?? null; - - return $value ?? null; + return $this->options['profiles_sample_rate']; } public function setProfilesSampleRate(?float $sampleRate): self @@ -209,6 +206,34 @@ public function setProfilesSampleRate(?float $sampleRate): self return $this; } + public function getProfilesSessionSampleRate(): ?float + { + return $this->options['profiles_session_sample_rate']; + } + + public function setProfilesSessionSampleRate(?float $sampleRate): self + { + $options = array_merge($this->options, ['profiles_session_sample_rate' => $sampleRate]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + + public function getProfilesLifecycle(): ?string + { + return $this->options['profiles_lifecycle']; + } + + public function setProfilesLifecycle(?string $lifecycle): self + { + $options = array_merge($this->options, ['profiles_lifecycle' => $lifecycle]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Gets whether tracing is enabled or not. The feature is enabled when at * least one of the `traces_sample_rate` and `traces_sampler` options is @@ -1223,6 +1248,8 @@ private function configureOptions(OptionsResolver $resolver): void 'traces_sample_rate' => null, 'traces_sampler' => null, 'profiles_sample_rate' => null, + 'profiles_session_sample_rate' => null, + 'profiles_lifecycle' => 'trace', 'attach_stacktrace' => false, /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. @@ -1293,6 +1320,8 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); + $resolver->setAllowedTypes('profiles_session_sample_rate', ['null', 'int', 'float']); + $resolver->setAllowedTypes('profiles_lifecycle', ['string']); $resolver->setAllowedTypes('attach_stacktrace', 'bool'); $resolver->setAllowedTypes('attach_metric_code_locations', 'bool'); $resolver->setAllowedTypes('context_lines', ['null', 'int']); @@ -1335,6 +1364,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('class_serializers', 'array'); $resolver->setAllowedValues('max_request_body_size', ['none', 'never', 'small', 'medium', 'always']); + $resolver->setAllowedValues('profiles_lifecycle', ['trace', 'manual']); $resolver->setAllowedValues('dsn', \Closure::fromCallable([$this, 'validateDsnOption'])); $resolver->setAllowedValues('max_breadcrumbs', \Closure::fromCallable([$this, 'validateMaxBreadcrumbsOptions'])); $resolver->setAllowedValues('class_serializers', \Closure::fromCallable([$this, 'validateClassSerializersOption'])); diff --git a/src/Profiles/ProfileChunk.php b/src/Profiles/ProfileChunk.php new file mode 100644 index 000000000..cb361ac94 --- /dev/null +++ b/src/Profiles/ProfileChunk.php @@ -0,0 +1,255 @@ +, + * samples: array, + * stacks: array>, + * }, + * client_sdk: array{ + * name: string, + * version: string, + * }, + * } + * @phpstan-type ExcimerLogStackEntryTrace array{ + * file: string, + * line: int, + * class?: string, + * function?: string, + * closure_line?: int, + * } + * @phpstan-type ExcimerLogStackEntry array{ + * trace: array, + * timestamp: float + * } + * + * @internal + */ +final class ProfileChunk +{ + use PrefixStripper; + + /** + * @var string The thread ID + */ + public const THREAD_ID = '0'; + + /** + * @var string The thread name + */ + public const THREAD_NAME = 'main'; + + /** + * @var string The version of the profile format + */ + private const VERSION = '2'; + + /** + * @var float|null The start time of the profile as a Unix timestamp with microseconds + */ + private $startTimeStamp; + + /** + * @var string|null The profiler ID + */ + private $profilerId; + + /** + * @var string|null The chunk ID (null = auto-generate) + */ + private $chunkId; + + /** + * @var array The data of the profile + */ + private $excimerLogs; + + /** + * @var Options|null + */ + private $options; + + public function __construct(?Options $options = null) + { + $this->options = $options; + } + + public function setStartTimeStamp(?float $startTimeStamp): void + { + $this->startTimeStamp = $startTimeStamp; + } + + public function setProfilerId(?string $profilerId): void + { + $this->profilerId = $profilerId; + } + + public function setChunkId(string $chunkId): void + { + $this->chunkId = $chunkId; + } + + /** + * @param array $excimerLogs + */ + public function setExcimerLogs($excimerLogs): void + { + $this->excimerLogs = $excimerLogs; + } + + /** + * @return SentryV2Profile|null + */ + public function getFormattedData(Event $event): ?array + { + $frames = []; + $frameHashMap = []; + + $stacks = []; + $stackHashMap = []; + + $registerStack = static function (array $stack) use (&$stacks, &$stackHashMap): int { + $stackHash = md5(serialize($stack)); + + if (\array_key_exists($stackHash, $stackHashMap) === false) { + $stackHashMap[$stackHash] = \count($stacks); + $stacks[] = $stack; + } + + return $stackHashMap[$stackHash]; + }; + + $samples = []; + + $loggedStacks = $this->prepareStacks(); + foreach ($loggedStacks as $stack) { + $stackFrames = []; + + foreach ($stack['trace'] as $frame) { + $absolutePath = $frame['file']; + $lineno = $frame['line']; + + $frameKey = "{$absolutePath}:{$lineno}"; + + $frameIndex = $frameHashMap[$frameKey] ?? null; + + if ($frameIndex === null) { + $file = $this->stripPrefixFromFilePath($this->options, $absolutePath); + $module = null; + + if (isset($frame['class'], $frame['function'])) { + // Class::method + $function = $frame['class'] . '::' . $frame['function']; + $module = $frame['class']; + } elseif (isset($frame['function'])) { + // {closure} + $function = $frame['function']; + } else { + // /index.php + $function = $file; + } + + $frameHashMap[$frameKey] = $frameIndex = \count($frames); + $frames[] = [ + 'filename' => $file, + 'abs_path' => $absolutePath, + 'module' => $module, + 'function' => $function, + 'lineno' => $lineno, + ]; + } + + $stackFrames[] = $frameIndex; + } + + $stackId = $registerStack($stackFrames); + + $samples[] = [ + 'stack_id' => $stackId, + 'thread_id' => self::THREAD_ID, + 'timestamp' => $this->startTimeStamp + $stack['timestamp'], + ]; + } + + return [ + 'profiler_id' => $this->profilerId, + 'chunk_id' => $this->chunkId ?? SentryUid::generate(), + 'platform' => 'php', + 'release' => $event->getRelease() ?? '', + 'environment' => $event->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT, + 'version' => self::VERSION, + 'profile' => [ + 'frames' => $frames, + 'samples' => $samples, + 'stacks' => $stacks, + 'thread_metadata' => (object) [ + self::THREAD_ID => [ + 'name' => self::THREAD_NAME, + ], + ], + ], + 'client_sdk' => [ + 'name' => $event->getSdkIdentifier(), + 'version' => $event->getSdkVersion(), + ], + ]; + } + + /** + * This method is mainly used to be able to mock the ExcimerLog class in the tests. + * + * @return array + */ + private function prepareStacks(): array + { + $stacks = []; + + foreach ($this->excimerLogs as $excimerLog) { + foreach ($excimerLog as $stack) { + if ($stack instanceof \ExcimerLogEntry) { + $stacks[] = [ + 'trace' => $stack->getTrace(), + 'timestamp' => $stack->getTimestamp(), + ]; + } else { + /** @var ExcimerLogStackEntry $stack */ + $stacks[] = $stack; + } + } + } + + return $stacks; + } +} diff --git a/src/Profiles/Profiler.php b/src/Profiles/Profiler.php new file mode 100644 index 000000000..a72c9cfa6 --- /dev/null +++ b/src/Profiles/Profiler.php @@ -0,0 +1,98 @@ +logger = $options !== null ? $options->getLoggerOrNullLogger() : new NullLogger(); + + $this->initProfiler(); + } + + public function start(): void + { + if ($this->profiler !== null) { + $this->profiler->start(); + } + } + + public function stop(): ?\ExcimerLog + { + if ($this->profiler !== null) { + $this->profiler->stop(); + + return $this->profiler->flush(); + } + + return null; + } + + public function getStartTimeStamp(): ?float + { + return $this->startTimeStamp; + } + + public function getProfilerId(): ?string + { + return $this->profilerId; + } + + private function initProfiler(): void + { + if (!\extension_loaded('excimer')) { + $this->logger->warning('The profiler was started but is not available because the "excimer" extension is not loaded.'); + + return; + } + + $this->profiler = new \ExcimerProfiler(); + $this->startTimeStamp = microtime(true); + $this->profilerId = SentryUid::generate(); + + $this->profiler->setEventType(\EXCIMER_REAL); + $this->profiler->setPeriod(self::SAMPLE_RATE); + $this->profiler->setMaxDepth(self::MAX_STACK_DEPTH); + } +} diff --git a/src/Profiles/Profiles.php b/src/Profiles/Profiles.php new file mode 100644 index 000000000..5f9701393 --- /dev/null +++ b/src/Profiles/Profiles.php @@ -0,0 +1,81 @@ +profiler = new Profiler($options); + + $this->aggregator = new ProfilesAggregator(); + $this->aggregator->setStartTimeStamp($this->profiler->getStartTimeStamp()); + $this->aggregator->setProfilerId($this->profiler->getProfilerId()); + } + + public static function getInstance(?Options $options = null): self + { + if (self::$instance === null) { + self::$instance = new self($options); + } + + return self::$instance; + } + + public function getProfiler(): Profiler + { + return $this->profiler; + } + + public function start(): void + { + $this->profiler->start(); + } + + public function stop(): void + { + $excimerLog = $this->profiler->stop(); + + if ($excimerLog !== null) { + $this->aggregator->add($excimerLog); + } + } + + /** + * Flush the captured profile chunks and send them to Sentry. + */ + public function flush(): ?EventId + { + return $this->aggregator->flush(); + } + + /** + * Get the profiles aggregator. + * + * @internal + */ + public function aggregator(): ProfilesAggregator + { + return $this->aggregator; + } +} diff --git a/src/Profiles/ProfilesAggregator.php b/src/Profiles/ProfilesAggregator.php new file mode 100644 index 000000000..fe8f01b73 --- /dev/null +++ b/src/Profiles/ProfilesAggregator.php @@ -0,0 +1,67 @@ +excimerLogs[] = $excimerLog; + } + + public function setStartTimeStamp(?float $startTimeStamp): void + { + $this->startTimeStamp = $startTimeStamp; + } + + public function setProfilerId(?string $profilerId): void + { + $this->profilerId = $profilerId; + } + + public function flush(): ?EventId + { + if (empty($this->excimerLogs)) { + return null; + } + + $profileChunk = new ProfileChunk(); + $profileChunk->setExcimerLogs($this->excimerLogs); + $profileChunk->setStartTimeStamp($this->startTimeStamp); + $profileChunk->setProfilerId($this->profilerId); + + $hub = SentrySdk::getCurrentHub(); + $event = Event::createProfileChunk()->setProfileChunk($profileChunk); + + $this->excimerLogs = []; + + return $hub->captureEvent($event); + } +} diff --git a/src/Profiling/Profile.php b/src/Profiling/Profile.php index 3e88e9cf2..d592e902d 100644 --- a/src/Profiling/Profile.php +++ b/src/Profiling/Profile.php @@ -15,10 +15,10 @@ use Sentry\Util\SentryUid; /** - * Type definition of the Sentry profile format. + * Type definition of the Sentry v1 profile format. * All fields are none otpional. * - * @see https://develop.sentry.dev/sdk/sample-format/ + * @see https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v1/ * * @phpstan-type SentryProfileFrame array{ * abs_path: string, diff --git a/src/Serializer/EnvelopItems/ProfileChunkItem.php b/src/Serializer/EnvelopItems/ProfileChunkItem.php new file mode 100644 index 000000000..2d172dfdd --- /dev/null +++ b/src/Serializer/EnvelopItems/ProfileChunkItem.php @@ -0,0 +1,35 @@ + (string) $event->getType(), + 'content_type' => 'application/json', + ]; + + $profileChunk = $event->getProfileChunk(); + if (!$profileChunk instanceof ProfileChunk) { + return ''; + } + + $payload = $profileChunk->getFormattedData($event); + if ($payload === null) { + return ''; + } + + return \sprintf("%s\n%s", JSON::encode($header), JSON::encode($payload)); + } +} diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index 4878cc767..c195c3863 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -10,6 +10,7 @@ use Sentry\Serializer\EnvelopItems\CheckInItem; use Sentry\Serializer\EnvelopItems\EventItem; use Sentry\Serializer\EnvelopItems\LogsItem; +use Sentry\Serializer\EnvelopItems\ProfileChunkItem; use Sentry\Serializer\EnvelopItems\ProfileItem; use Sentry\Serializer\EnvelopItems\TransactionItem; use Sentry\Tracing\DynamicSamplingContext; @@ -73,6 +74,9 @@ public function serialize(Event $event): string case EventType::logs(): $items[] = LogsItem::toEnvelopeItem($event); break; + case EventType::profileChunk(): + $items[] = ProfileChunkItem::toEnvelopeItem($event); + break; } return \sprintf("%s\n%s", JSON::encode($envelopeHeader), implode("\n", array_filter($items))); diff --git a/src/State/Hub.php b/src/State/Hub.php index bd51b440b..4a5489cd6 100644 --- a/src/State/Hub.php +++ b/src/State/Hub.php @@ -323,16 +323,7 @@ public function startTransaction(TransactionContext $context, array $customSampl $transaction->initSpanRecorder(); - $profilesSampleRate = $options->getProfilesSampleRate(); - if ($profilesSampleRate === null) { - $logger->info(\sprintf('Transaction [%s] is not profiling because `profiles_sample_rate` option is not set.', (string) $transaction->getTraceId())); - } elseif ($this->sample($profilesSampleRate)) { - $logger->info(\sprintf('Transaction [%s] started profiling because it was sampled.', (string) $transaction->getTraceId())); - - $transaction->initProfiler()->start(); - } else { - $logger->info(\sprintf('Transaction [%s] is not profiling because it was not sampled.', (string) $transaction->getTraceId())); - } + $this->initProfiler($transaction, $options, $logger); return $transaction; } @@ -423,4 +414,34 @@ private function isValidSampleRate($sampleRate): bool return true; } + + private function initProfiler(Transaction $transaction, $options, $logger): void + { + $profileSessionSampleRate = $options->getProfilesSessionSampleRate(); + $profilesLifecycle = $options->getProfilesLifecycle(); + + // profileSessionSampleRate takes priority + if ($profilesLifecycle === 'trace' && $profileSessionSampleRate !== null) { + if ($this->sample($profileSessionSampleRate)) { + $logger->info(\sprintf('Transaction [%s] started profiling because it was sampled by profiles_session_sample_rate.', (string) $transaction->getTraceId())); + $transaction->initProfiler()->start(); + } else { + $logger->info(\sprintf('Transaction [%s] is not profiling because it was not sampled by profiles_session_sample_rate.', (string) $transaction->getTraceId())); + } + + return; + } + + // Fall back to profilesSampleRate + $profilesSampleRate = $options->getProfilesSampleRate(); + + if ($profilesSampleRate === null) { + $logger->info(\sprintf('Transaction [%s] is not profiling because `profiles_sample_rate` option is not set.', (string) $transaction->getTraceId())); + } elseif ($this->sample($profilesSampleRate)) { + $logger->info(\sprintf('Transaction [%s] started profiling because it was sampled.', (string) $transaction->getTraceId())); + $transaction->initProfiler()->start(); + } else { + $logger->info(\sprintf('Transaction [%s] is not profiling because it was not sampled.', (string) $transaction->getTraceId())); + } + } } diff --git a/src/Tracing/Transaction.php b/src/Tracing/Transaction.php index 5e4d727d2..135ab1230 100644 --- a/src/Tracing/Transaction.php +++ b/src/Tracing/Transaction.php @@ -6,6 +6,8 @@ use Sentry\Event; use Sentry\EventId; +use Sentry\Profiles\ProfileChunk; +use Sentry\Profiles\Profiler as ContinuousProfiler; use Sentry\Profiling\Profiler; use Sentry\SentrySdk; use Sentry\State\HubInterface; @@ -36,7 +38,7 @@ final class Transaction extends Span protected $metadata; /** - * @var Profiler|null Reference instance to the {@see Profiler} + * @var Profiler|ContinuousProfiler|null Reference instance to the {@see Profiler or @see ContinuousProfiler} */ protected $profiler; @@ -119,19 +121,31 @@ public function initSpanRecorder(int $maxSpans = 1000): self return $this; } - public function initProfiler(): Profiler + /** + * @return Profiler|ContinuousProfiler|null + */ + public function initProfiler() { if ($this->profiler === null) { $client = $this->hub->getClient(); $options = $client !== null ? $client->getOptions() : null; - $this->profiler = new Profiler($options); + if ($options !== null) { + if ($options->getProfilesLifecycle() === 'trace' && $options->getProfilesSessionSampleRate() !== null) { + $this->profiler = new ContinuousProfiler($options); + } else { + $this->profiler = new Profiler($options); + } + } } return $this->profiler; } - public function getProfiler(): ?Profiler + /** + * @return Profiler|ContinuousProfiler|null + */ + public function getProfiler() { return $this->profiler; } @@ -183,13 +197,31 @@ public function finish(?float $endTimestamp = null): ?EventId $event->setSdkMetadata('dynamic_sampling_context', $this->getDynamicSamplingContext()); $event->setSdkMetadata('transaction_metadata', $this->getMetadata()); - if ($this->profiler !== null) { + // Legacy Profiler + if ($this->profiler !== null && $this->profiler instanceof Profiler) { $profile = $this->profiler->getProfile(); if ($profile !== null) { $event->setSdkMetadata('profile', $profile); } } + // Continuous Profiler + if ($this->profiler !== null && $this->profiler instanceof ContinuousProfiler) { + $event->setContext('profile', [ + 'profiler_id' => $this->profiler->getProfilerId(), + ]); + + $traceContext = $this->getTraceContext(); + $traceContext['data'] = array_merge( + $traceContext['data'] ?? [], + [ + 'thread.id' => ProfileChunk::THREAD_ID, + 'thread.name' => ProfileChunk::THREAD_NAME, + ] + ); + $event->setContext('trace', $traceContext); + } + return $this->hub->captureEvent($event); } } diff --git a/src/functions.php b/src/functions.php index 573ef7be8..084dfc629 100644 --- a/src/functions.php +++ b/src/functions.php @@ -9,6 +9,7 @@ use Sentry\Integration\IntegrationInterface; use Sentry\Logs\Logs; use Sentry\Metrics\Metrics; +use Sentry\Profiles\Profiles; use Sentry\State\Scope; use Sentry\Tracing\PropagationContext; use Sentry\Tracing\SpanContext; @@ -372,6 +373,23 @@ function logger(): Logs return Logs::getInstance(); } +/** + * Get the Sentry Profiler client. + */ +function profiler(): Profiles +{ + $hub = SentrySdk::getCurrentHub(); + + $options = null; + + $client = $hub->getClient(); + if ($client !== null) { + $options = $client->getOptions(); + } + + return Profiles::getInstance($options); +} + /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 4192781a0..f04f8b5d3 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -138,6 +138,20 @@ static function (): void {}, 'setProfilesSampleRate', ]; + yield [ + 'profiles_session_sample_rate', + 0.5, + 'getProfilesSessionSampleRate', + 'setProfilesSessionSampleRate', + ]; + + yield [ + 'profiles_lifecycle', + 'manual', + 'getProfilesLifecycle', + 'setProfilesLifecycle', + ]; + yield [ 'attach_stacktrace', false, @@ -630,6 +644,64 @@ public static function contextLinesOptionValidatesInputValueDataProvider(): \Gen ]; } + /** + * @dataProvider profileLifecycleOptionValidatesInputValueDataProvider + */ + public function testProfileLifecycleOptionValidatesInputValue($value, ?string $expectedExceptionMessage): void + { + if ($expectedExceptionMessage !== null) { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + } else { + $this->expectNotToPerformAssertions(); + } + + new Options(['profile_lifecycle' => $value]); + } + + public static function profileLifecycleOptionValidatesInputValueDataProvider(): \Generator + { + yield [ + 'trace', + null, + ]; + + yield [ + 'manual', + null, + ]; + + yield [ + null, + null, + ]; + + yield [ + 'invalid', + 'The option "profile_lifecycle" with value "invalid" is invalid.', + ]; + + yield [ + 'auto', + 'The option "profile_lifecycle" with value "auto" is invalid.', + ]; + + yield [ + '', + 'The option "profile_lifecycle" with value "" is invalid.', + ]; + + yield [ + 123, + 'The option "profile_lifecycle" with value 123 is expected to be of type "null" or "string", but is of type "int".', + ]; + + yield [ + true, + 'The option "profile_lifecycle" with value true is expected to be of type "null" or "string", but is of type "bool".', + ]; + } + /** * @backupGlobals enabled */ diff --git a/tests/Profiles/ProfileChunkTest.php b/tests/Profiles/ProfileChunkTest.php new file mode 100644 index 000000000..8bea2e68b --- /dev/null +++ b/tests/Profiles/ProfileChunkTest.php @@ -0,0 +1,344 @@ +setStartTimeStamp(1749368460.0000); + $profileChunk->setProfilerId('550e8400e29b41d4a716446655440000'); + $profileChunk->setChunkId('a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d'); + $profileChunk->setExcimerLogs($excimerLogs); + + $this->assertEquals($expectedData, $profileChunk->getFormattedData($event)); + } + + public static function formattedDataDataProvider(): \Generator + { + $event = Event::createProfileChunk(); + $event->setRelease('1.0.0'); + $event->setEnvironment('dev'); + $event->setSdkIdentifier('sentry.php'); + $event->setSdkVersion('4.12.0'); + + $excimerLogData = [ + [ + [ + 'trace' => [ + [ + 'file' => '/var/www/html/index.php', + 'line' => 42, + ], + ], + 'timestamp' => 0.001, + ], + [ + 'trace' => [ + [ + 'file' => '/var/www/html/index.php', + 'line' => 42, + ], + ], + 'timestamp' => 0.002, + ], + [ + 'trace' => [ + [ + 'file' => '/var/www/html/index.php', + 'line' => 42, + ], + [ + 'class' => 'Function', + 'function' => 'doStuff', + 'file' => '/var/www/html/function.php', + 'line' => 84, + ], + ], + 'timestamp' => 0.003, + ], + [ + 'trace' => [ + [ + 'file' => '/var/www/html/index.php', + 'line' => 42, + ], + [ + 'class' => 'Function', + 'function' => 'doStuff', + 'file' => '/var/www/html/function.php', + 'line' => 84, + ], + [ + 'class' => 'Class\Something', + 'function' => 'run', + 'file' => '/var/www/html/class.php', + 'line' => 42, + ], + [ + 'function' => '{closure}', + 'file' => '/var/www/html/index.php', + 'line' => 126, + ], + ], + 'timestamp' => 0.004, + ], + ], + ]; + + yield 'Basic profiling data' => [ + $event, + $excimerLogData, + [ + 'profiler_id' => '550e8400e29b41d4a716446655440000', + 'chunk_id' => 'a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d', + 'platform' => 'php', + 'release' => '1.0.0', + 'environment' => 'dev', + 'version' => '2', + 'profile' => [ + 'frames' => [ + [ + 'filename' => '/var/www/html/index.php', + 'abs_path' => '/var/www/html/index.php', + 'module' => null, + 'function' => '/var/www/html/index.php', + 'lineno' => 42, + ], + [ + 'filename' => '/var/www/html/function.php', + 'abs_path' => '/var/www/html/function.php', + 'module' => 'Function', + 'function' => 'Function::doStuff', + 'lineno' => 84, + ], + [ + 'filename' => '/var/www/html/class.php', + 'abs_path' => '/var/www/html/class.php', + 'module' => 'Class\Something', + 'function' => 'Class\Something::run', + 'lineno' => 42, + ], + [ + 'filename' => '/var/www/html/index.php', + 'abs_path' => '/var/www/html/index.php', + 'module' => null, + 'function' => '{closure}', + 'lineno' => 126, + ], + ], + 'samples' => [ + [ + 'stack_id' => 0, + 'thread_id' => '0', + 'timestamp' => 1749368460.001, + ], + [ + 'stack_id' => 0, + 'thread_id' => '0', + 'timestamp' => 1749368460.002, + ], + [ + 'stack_id' => 1, + 'thread_id' => '0', + 'timestamp' => 1749368460.003, + ], + [ + 'stack_id' => 2, + 'thread_id' => '0', + 'timestamp' => 1749368460.004, + ], + ], + 'stacks' => [ + [ + 0, + ], + [ + 0, + 1, + ], + [ + 0, + 1, + 2, + 3, + ], + ], + 'thread_metadata' => (object) [ + '0' => [ + 'name' => 'main', + ], + ], + ], + 'client_sdk' => [ + 'name' => 'sentry.php', + 'version' => '4.12.0', + ], + ], + ]; + + yield 'With prefix stripping options' => [ + $event, + $excimerLogData, + [ + 'profiler_id' => '550e8400e29b41d4a716446655440000', + 'chunk_id' => 'a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d', + 'platform' => 'php', + 'release' => '1.0.0', + 'environment' => 'dev', + 'version' => '2', + 'profile' => [ + 'frames' => [ + [ + 'filename' => '/index.php', + 'abs_path' => '/var/www/html/index.php', + 'module' => null, + 'function' => '/index.php', + 'lineno' => 42, + ], + [ + 'filename' => '/function.php', + 'abs_path' => '/var/www/html/function.php', + 'module' => 'Function', + 'function' => 'Function::doStuff', + 'lineno' => 84, + ], + [ + 'filename' => '/class.php', + 'abs_path' => '/var/www/html/class.php', + 'module' => 'Class\Something', + 'function' => 'Class\Something::run', + 'lineno' => 42, + ], + [ + 'filename' => '/index.php', + 'abs_path' => '/var/www/html/index.php', + 'module' => null, + 'function' => '{closure}', + 'lineno' => 126, + ], + ], + 'samples' => [ + [ + 'stack_id' => 0, + 'thread_id' => '0', + 'timestamp' => 1749368460.001, + ], + [ + 'stack_id' => 0, + 'thread_id' => '0', + 'timestamp' => 1749368460.002, + ], + [ + 'stack_id' => 1, + 'thread_id' => '0', + 'timestamp' => 1749368460.003, + ], + [ + 'stack_id' => 2, + 'thread_id' => '0', + 'timestamp' => 1749368460.004, + ], + ], + 'stacks' => [ + [ + 0, + ], + [ + 0, + 1, + ], + [ + 0, + 1, + 2, + 3, + ], + ], + 'thread_metadata' => (object) [ + '0' => [ + 'name' => 'main', + ], + ], + ], + 'client_sdk' => [ + 'name' => 'sentry.php', + 'version' => '4.12.0', + ], + ], + new Options([ + 'prefixes' => ['/var/www/html'], + ]), + ]; + + yield 'Function without class' => [ + $event, + [ + [ + [ + 'trace' => [ + [ + 'function' => 'array_map', + 'file' => '/var/www/html/index.php', + 'line' => 42, + ], + ], + 'timestamp' => 0.001, + ], + ], + ], + [ + 'profiler_id' => '550e8400e29b41d4a716446655440000', + 'chunk_id' => 'a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d', + 'platform' => 'php', + 'release' => '1.0.0', + 'environment' => 'dev', + 'version' => '2', + 'profile' => [ + 'frames' => [ + [ + 'filename' => '/var/www/html/index.php', + 'abs_path' => '/var/www/html/index.php', + 'module' => null, + 'function' => 'array_map', + 'lineno' => 42, + ], + ], + 'samples' => [ + [ + 'stack_id' => 0, + 'thread_id' => '0', + 'timestamp' => 1749368460.001, + ], + ], + 'stacks' => [ + [ + 0, + ], + ], + 'thread_metadata' => (object) [ + '0' => [ + 'name' => 'main', + ], + ], + ], + 'client_sdk' => [ + 'name' => 'sentry.php', + 'version' => '4.12.0', + ], + ], + ]; + } +} diff --git a/tests/Profiles/ProfilesAggregatorTest.php b/tests/Profiles/ProfilesAggregatorTest.php new file mode 100644 index 000000000..e7f3885e7 --- /dev/null +++ b/tests/Profiles/ProfilesAggregatorTest.php @@ -0,0 +1,82 @@ +createMock(ClientInterface::class); + $client->method('captureEvent') + ->willReturn(new EventId('fc9442f5aef34234bb22b9a615e30ccd')); + + SentrySdk::getCurrentHub()->bindClient($client); + } + + public function testAddExcimerLog(): void + { + $this->setupSentryClient(); + + $aggregator = new ProfilesAggregator(); + $aggregator->setStartTimeStamp(1734604860.0); + $aggregator->setProfilerId('550e8400e29b41d4a716446655440000'); + + $mockExcimerLog = $this->createMockExcimerLog(); + + $aggregator->add($mockExcimerLog); + + $this->assertInstanceOf(EventId::class, $aggregator->flush()); + } + + public function testFlushWithEmptyLogs(): void + { + $aggregator = new ProfilesAggregator(); + + $result = $aggregator->flush(); + + $this->assertNull($result); + } + + public function testFlushClearsLogs(): void + { + $this->setupSentryClient(); + + $aggregator = new ProfilesAggregator(); + $aggregator->setStartTimeStamp(1734604860.0); + $aggregator->setProfilerId('550e8400e29b41d4a716446655440000'); + + $mockExcimerLog = $this->createMockExcimerLog(); + $aggregator->add($mockExcimerLog); + + // First flush should return EventId + $this->assertInstanceOf(EventId::class, $aggregator->flush()); + + // Second flush should return null since logs were cleared + $this->assertNull($aggregator->flush()); + } + + private function createMockExcimerLog(): \Iterator + { + return new \ArrayIterator([ + [ + 'trace' => [ + [ + 'file' => '/var/www/html/index.php', + 'line' => 42, + ], + ], + 'timestamp' => 0.001, + ], + ]); + } +}