Skip to content
Open
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
2 changes: 1 addition & 1 deletion analysis-baseline.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1066,7 +1066,7 @@ count = 2
file = "src/Profiling/ContinuousProfiler.php"
code = "invalid-method-access"
message = "Attempting to access a method on a non-object type (`unknown-ref(ExcimerProfiler)`)."
count = 4
count = 5

[[issues]]
file = "src/Profiling/ContinuousProfiler.php"
Expand Down
65 changes: 62 additions & 3 deletions src/Profiling/ContinuousProfiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ final class ContinuousProfiler
*/
private $profilerId;

/**
* @var int|false|null The ID of the process the running profiler samples
*/
private $processId;

/**
* @var float|null The start time of the running profiler as a Unix timestamp with microseconds
*/
Expand Down Expand Up @@ -109,6 +114,8 @@ public function __construct(int $maxSamplesPerChunk = self::MAX_SAMPLES_PER_CHUN

public function start(): void
{
$this->restartInForkedProcess();

if ($this->profiler !== null) {
return;
}
Expand Down Expand Up @@ -166,6 +173,7 @@ public function start(): void

$this->profiler = $profiler;
$this->profilerId = $profilerId;
$this->processId = getmypid();
$this->startTimestamp = $startTimestamp;
$this->nextFlushTimestamp = $startTimestamp + $this->flushInterval;

Expand Down Expand Up @@ -219,6 +227,8 @@ public function stopForRuntimeContext(string $runtimeContextId): void

public function stop(): void
{
$this->restartInForkedProcess();

if ($this->profiler === null) {
return;
}
Expand All @@ -230,14 +240,15 @@ public function stop(): void

$this->capturePendingChunks();

$this->profiler = null;
$this->profilerId = null;
$this->startTimestamp = null;
$this->clearProfiler();

$this->activeRootSpans = [];
}

public function flush(): void
{
$this->restartInForkedProcess();

if ($this->profiler === null) {
return;
}
Expand All @@ -249,6 +260,8 @@ public function flush(): void

public function capturePendingChunks(): void
{
$this->restartInForkedProcess();

if ($this->profiler !== null && microtime(true) >= $this->nextFlushTimestamp) {
$this->profiler->flush();
}
Expand All @@ -274,8 +287,14 @@ public function isRunning(): bool
return $this->profiler !== null;
}

/**
* Gets the ID of the running profiler, which is restarted first if the process was forked,
* so that spans created in the forked process are linked to the profiler sampling it.
*/
public function getProfilerId(): ?string
{
$this->restartInForkedProcess();

return $this->profilerId;
}

Expand Down Expand Up @@ -334,6 +353,46 @@ private function completeChunk(array $entries): void
$this->pendingChunks->push(new ProfileChunk($this->profilerId, $this->startTimestamp, $profileData, $client->getOptions()));
}

private function clearProfiler(): void
{
// Clear the flush callback so that the samples are discarded
if ($this->profiler !== null) {
$this->profiler->clearFlushCallback();
}

$this->profiler = null;
$this->profilerId = null;
$this->processId = null;
$this->startTimestamp = null;

// A forked process inherits the completed chunks, which the parent process captures itself
$this->pendingChunks->drain();
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Excimer does not sample in a forked process, so the profiler inherited from the parent process
* is replaced by a new one sampling this process, while the parent process keeps its own samples.
*/
private function restartInForkedProcess(): void
{
if ($this->profiler === null || $this->processId === getmypid()) {
return;
}

// The timer of the inherited profiler does not exist in this process, so tearing it down emits warnings
set_error_handler(static function (): bool {
return true;
});

try {
$this->clearProfiler();
} finally {
restore_error_handler();
}

$this->start();
Comment thread
cursor[bot] marked this conversation as resolved.
}

private function isSessionSampled(?float $sampleRate): bool
{
if ($sampleRate === null || $sampleRate <= 0.0) {
Expand Down
174 changes: 174 additions & 0 deletions tests/Profiling/ContinuousProfilerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,185 @@ public function testChunksAreSplitByDuration(): void
}
}

/**
* A forked process inherits the memory of the running profiler, but not the timer Excimer
* samples with, so it would not be profiled anymore.
*
* The profiler is started and the process forked. The forked process asks for the profiler ID,
* which replaces the inherited profiler with a new one without emitting any error, collects
* samples and stops the profiler. The test then asserts that the forked process sent a chunk
* with a new profiler ID, while the parent process keeps profiling with its own profiler.
*
* @requires extension excimer
* @requires extension pcntl
*/
public function testForkedProcessIsProfiledByANewProfiler(): void
{
$this->bindClient(['profile_session_sample_rate' => 1.0]);

$profiler = new ContinuousProfiler();
$profiler->start();

$parentProfilerId = $profiler->getProfilerId();

$result = $this->runInForkedProcess(static function () use ($profiler): array {
StubTransport::$events = [];

error_clear_last();

$childProfilerId = $profiler->getProfilerId();

self::busyWait(0.2);

$profiler->stop();

return [
'lastError' => error_get_last(),
'childProfilerId' => $childProfilerId,
'chunkProfilerIds' => self::getChunkProfilerIds(),
];
});

$this->assertNull($result['lastError']);
$this->assertNotNull($result['childProfilerId']);
$this->assertNotSame($parentProfilerId, $result['childProfilerId']);
$this->assertSame([$result['childProfilerId']], $result['chunkProfilerIds']);

// The parent process keeps profiling with its own profiler
$this->assertSame($parentProfilerId, $profiler->getProfilerId());

self::busyWait(0.05);

$profiler->stop();

$this->assertCount(1, StubTransport::$events);
$this->assertSame($parentProfilerId, StubTransport::$events[0]->getProfileChunk()->getProfilerId());
}

/**
* Destroying the profiler inherited by a forked process passes the samples it has not flushed yet
* to the flush callback. The parent process sends those samples itself, so the forked process must
* not send them under the profiler ID of the parent process.
*
* The samples are buffered in batches and the chunks are short, so the samples collected before the
* fork would fill several chunks while the inherited profiler is replaced.
*
* @requires extension excimer
* @requires extension pcntl
*/
public function testForkedProcessDoesNotSendTheSamplesOfTheParentProcess(): void
{
$this->bindClient(['profile_session_sample_rate' => 1.0]);

$profiler = new ContinuousProfiler(1000, 0.1, 0.1);
$profiler->start();

self::busyWait(0.3);

$result = $this->runInForkedProcess(static function () use ($profiler): array {
StubTransport::$events = [];

$profiler->getProfilerId();
$profiler->capturePendingChunks();

return ['chunkProfilerIds' => self::getChunkProfilerIds()];
});

$this->assertSame([], $result['chunkProfilerIds']);

$profiler->stop();
}

/**
* The chunks completed while sampling wait in the memory that a forked process inherits. The parent
* process captures them itself, so the forked process must not capture them under the profiler ID of
* the parent process.
*
* @requires extension excimer
* @requires extension pcntl
*/
public function testForkedProcessDoesNotCaptureThePendingChunksOfTheParentProcess(): void
{
$this->bindClient(['profile_session_sample_rate' => 1.0]);

$profiler = new ContinuousProfiler(5);
$profiler->start();

$profilerId = $profiler->getProfilerId();

self::busyWait(0.2);

$result = $this->runInForkedProcess(static function () use ($profiler): array {
StubTransport::$events = [];

$profiler->capturePendingChunks();

return ['chunkProfilerIds' => self::getChunkProfilerIds()];
});

$this->assertSame([], $result['chunkProfilerIds']);

$profiler->capturePendingChunks();

$this->assertNotEmpty(StubTransport::$events);
$this->assertSame([$profilerId], array_values(array_unique(self::getChunkProfilerIds())));

$profiler->stop();
}

private function bindClient(array $options): void
{
SentrySdk::init()->bindClient(new Client(new Options($options), StubTransport::getInstance()));
}

/**
* Runs the callback in a forked process and returns its result. As the forked process cannot
* report to the test directly, it writes its result to a file and kills itself, so the test
* runner does not shut down a second time.
*
* @param callable(): array<string, mixed> $callback
*
* @return array<string, mixed>
*/
private function runInForkedProcess(callable $callback): array
{
$resultFile = tempnam(sys_get_temp_dir(), 'sentry-profiler-fork');

$pid = pcntl_fork();

if ($pid === 0) {
try {
$result = $callback();
} catch (\Throwable $exception) {
$result = ['error' => (string) $exception];
}

file_put_contents($resultFile, json_encode($result));

// Exits without running the shutdown of the test runner in the forked process
posix_kill(getmypid(), \SIGKILL);
}

pcntl_waitpid($pid, $status);

$result = json_decode((string) file_get_contents($resultFile), true);
unlink($resultFile);

$this->assertArrayNotHasKey('error', $result, $result['error'] ?? '');

return $result;
}

/**
* @return array<int, string|null>
*/
private static function getChunkProfilerIds(): array
{
return array_map(static function (Event $event): ?string {
return $event->getProfileChunk() !== null ? $event->getProfileChunk()->getProfilerId() : null;
}, StubTransport::$events);
}

private static function busyWait(float $seconds): void
{
$end = microtime(true) + $seconds;
Expand Down
Loading