Skip to content

Continuous Profiling #1854

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/EventType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
38 changes: 34 additions & 4 deletions src/Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,7 @@

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'];

Check failure on line 197 in src/Options.php

View workflow job for this annotation

GitHub Actions / PHPStan

Method Sentry\Options::getProfilesSampleRate() should return float|null but returns mixed.
}

public function setProfilesSampleRate(?float $sampleRate): self
Expand All @@ -209,6 +206,34 @@
return $this;
}

public function getProfilesSessionSampleRate(): ?float
{
return $this->options['profiles_session_sample_rate'];

Check failure on line 211 in src/Options.php

View workflow job for this annotation

GitHub Actions / PHPStan

Method Sentry\Options::getProfilesSessionSampleRate() should return float|null but returns mixed.
}

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'];

Check failure on line 225 in src/Options.php

View workflow job for this annotation

GitHub Actions / PHPStan

Method Sentry\Options::getProfilesLifecycle() should return string|null but returns mixed.
}

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
Expand Down Expand Up @@ -1223,6 +1248,8 @@
'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.
Expand Down Expand Up @@ -1293,6 +1320,8 @@
$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']);
Expand Down Expand Up @@ -1335,6 +1364,7 @@
$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']));
Expand Down
255 changes: 255 additions & 0 deletions src/Profiles/ProfileChunk.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
<?php

declare(strict_types=1);

namespace Sentry\Profiles;

use Sentry\Event;
use Sentry\Options;
use Sentry\Util\PrefixStripper;
use Sentry\Util\SentryUid;

/**
* Type definition of the Sentry v2 profile format (continuous profiling).
* All fields are none otpional.
*
* @see https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/
*
* @phpstan-type SentryProfileFrame array{
* abs_path: string,
* filename: string,
* function: string,
* module: string|null,
* lineno: int|null,
* }
* @phpstan-type SentryV2Profile array{
* profiler_id: string,
* chunk_id: string,
* platform: string,
* release: string,
* environment: string,
* version: string,
* profile: array{
* frames: array<int, SentryProfileFrame>,
* samples: array<int, array{
* thread_id: string,
* stack_id: int,
* timestamp: float,
* }>,
* stacks: array<int, array<int, int>>,
* },
* 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<int, ExcimerLogStackEntryTrace>,
* 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<int, \ExcimerLog> 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<int, \ExcimerLog> $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 [

Check failure on line 206 in src/Profiles/ProfileChunk.php

View workflow job for this annotation

GitHub Actions / PHPStan

Method Sentry\Profiles\ProfileChunk::getFormattedData() should return array{profiler_id: string, chunk_id: string, platform: string, release: string, environment: string, version: string, profile: array{frames: array<int, array{abs_path: string, filename: string, function: string, module: string|null, lineno: int|null}>, samples: array<int, array{thread_id: string, stack_id: int, timestamp: float}>, stacks: array<int, array<int, int>>}, client_sdk: array{name: string, version: string}}|null but returns array{profiler_id: string|null, chunk_id: string, platform: 'php', release: string, environment: string, version: '2', profile: array{frames: array<int<0, max>, array{filename: string, abs_path: string, module: string|null, function: string, lineno: int}>, samples: array<int<0, max>, array{stack_id: int, thread_id: '0', timestamp: float}>, stacks: array<int<0, max>, array>, thread_metadata: object{}&stdClass}, client_sdk: array{name: string, version: string}}.
'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<int, ExcimerLogStackEntry>
*/
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;
}
}
Loading
Loading