diff options
-rw-r--r-- | .reuse/dep5 | 4 | ||||
-rw-r--r-- | build/stubs/excimer.php | 282 | ||||
-rw-r--r-- | config/config.sample.php | 59 | ||||
-rw-r--r-- | lib/base.php | 17 | ||||
-rw-r--r-- | lib/composer/composer/autoload_classmap.php | 1 | ||||
-rw-r--r-- | lib/composer/composer/autoload_static.php | 1 | ||||
-rw-r--r-- | lib/private/Profiler/BuiltInProfiler.php | 95 | ||||
-rw-r--r-- | psalm.xml | 1 |
8 files changed, 457 insertions, 3 deletions
diff --git a/.reuse/dep5 b/.reuse/dep5 index a2f9ae2b5f7..486c713f6aa 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -131,6 +131,10 @@ Files: build/stubs/pcntl.php build/stubs/zip.php Copyright: 2022 JetBrains License: Apache-2.0 +Files: build/stubs/excimer.php +Copyright: 2019 Wikimedia Foundation +License: Apache-2.0 + Files: core/js/mimetypelist.js core/js/core.json themes/example/core/img Copyright: 2016 ownCloud, Inc., 2016-2024 Nextcloud GmbH and Nextcloud contributors License: AGPL-3.0-only diff --git a/build/stubs/excimer.php b/build/stubs/excimer.php new file mode 100644 index 00000000000..e29eb2fd219 --- /dev/null +++ b/build/stubs/excimer.php @@ -0,0 +1,282 @@ +<?php + +/** Real (wall-clock) time */ +define('EXCIMER_REAL', 0); + +/** CPU time (user and system) consumed by the thread during execution */ +define('EXCIMER_CPU', 1); + +/** + * A sampling profiler. + * + * Collects a stack trace every time a timer event fires. + */ +class ExcimerProfiler { + /** + * Set the period. + * + * This will take effect the next time start() is called. + * + * If this method is not called, the default period of 0.1 seconds + * will be used. + * + * @param float $period The period in seconds + */ + public function setPeriod($period) { + } + + /** + * Set the event type. May be either EXCIMER_REAL, for real (wall-clock) + * time, or EXCIMER_CPU, for CPU time. The default is EXCIMER_REAL. + * + * This will take effect the next time start() is called. + * + * @param int $eventType + */ + public function setEventType($eventType) { + } + + /** + * Set the maximum depth of stack trace collection. If this depth is + * exceeded, the traversal up the stack will be terminated, so the function + * will appear to have no caller. + * + * By default, there is no limit. If this is called with a depth of zero, + * the limit is disabled. + * + * This will take effect immediately. + * + * @param int $maxDepth + */ + public function setMaxDepth($maxDepth) { + } + + /** + * Set a callback which will be called once the specified number of samples + * has been collected. + * + * When the ExcimerProfiler object is destroyed, the callback will also + * be called, unless no samples have been collected. + * + * The callback will be called with a single argument: the ExcimerLog + * object containing the samples. Before the callback is called, a new + * ExcimerLog object will be created and registered with the + * ExcimerProfiler. So ExcimerProfiler::getLog() should not be used from + * the callback, since it will not return the samples. + * + * @param callable $callback + * @param int $maxSamples + */ + public function setFlushCallback($callback, $maxSamples) { + } + + /** + * Clear the flush callback. No callback will be called regardless of + * how many samples are collected. + */ + public function clearFlushCallback() { + } + + /** + * Start the profiler. If the profiler was already running, it will be + * stopped and restarted with new options. + */ + public function start() { + } + + /** + * Stop the profiler. + */ + public function stop() { + } + + /** + * Get the current ExcimerLog object. + * + * Note that if the profiler is running, the object thus returned may be + * modified by a timer event at any time, potentially invalidating your + * analysis. Instead, the profiler should be stopped first, or flush() + * should be used. + * + * @return ExcimerLog + */ + public function getLog() { + } + + /** + * Create and register a new ExcimerLog object, and return the old + * ExcimerLog object. + * + * This will return all accumulated events to this point, and reset the + * log with a new log of zero length. + * + * @return ExcimerLog + */ + public function flush() { + } +} + +/** + * A collected series of stack traces and some utility methods to aggregate them. + * + * ExcimerLog acts as a container for ExcimerLogEntry objects. The Iterator or + * ArrayAccess interfaces may be used to access them. For example: + * + * foreach ( $profiler->getLog() as $entry ) { + * var_dump( $entry->getTrace() ); + * } + */ +class ExcimerLog implements ArrayAccess, Iterator { + /** + * ExcimerLog is not constructible by user code. Objects of this type + * are available via: + * - ExcimerProfiler::getLog() + * - ExcimerProfiler::flush() + * - The callback to ExcimerProfiler::setFlushCallback() + */ + final private function __construct() { + } + + /** + * Aggregate the stack traces and convert them to a line-based format + * understood by Brendan Gregg's FlameGraph utility. Each stack trace is + * represented as a series of function names, separated by semicolons. + * After this identifier, there is a single space character, then a number + * giving the number of times the stack appeared. Then there is a line + * break. This is repeated for each unique stack trace. + * + * @return string + */ + public function formatCollapsed() { + } + + /** + * Produce an array with an element for every function which appears in + * the log. The key is a human-readable unique identifier for the function, + * method or closure. The value is an associative array with the following + * elements: + * + * - self: The number of events in which the function itself was running, + * no other userspace function was being called. This includes time + * spent in internal functions that this function called. + * - inclusive: The number of events in which this function appeared + * somewhere in the stack. + * + * And optionally the following elements, if they are relevant: + * + * - file: The filename in which the function appears + * - line: The exact line number at which the first relevant event + * occurred. + * - class: The class name in which the method is defined + * - function: The name of the function or method + * - closure_line: The line number at which the closure was defined + * + * The event counts in the "self" and "inclusive" fields are adjusted for + * overruns. They represent an estimate of the number of profiling periods + * in which those functions were present. + * + * @return array + */ + public function aggregateByFunction() { + } + + /** + * Get an array which can be JSON encoded for import into speedscope + * + * @return array + */ + public function getSpeedscopeData() { + } + + /** + * Get the total number of profiling periods represented by this log. + * + * @return int + */ + public function getEventCount() { + } + + /** + * Get the current ExcimerLogEntry object. Part of the Iterator interface. + * + * @return ExcimerLogEntry|null + */ + public function current() { + } + + /** + * Get the current integer key or null. Part of the Iterator interface. + * + * @return int|null + */ + public function key() { + } + + /** + * Advance to the next log entry. Part of the Iterator interface. + */ + public function next() { + } + + /** + * Rewind back to the first log entry. Part of the Iterator interface. + */ + public function rewind() { + } + + /** + * Check if the current position is valid. Part of the Iterator interface. + * + * @return bool + */ + public function valid() { + } + + /** + * Get the number of log entries contained in this log. This is always less + * than or equal to the number returned by getEventCount(), which includes + * overruns. + * + * @return int + */ + public function count() { + } + + /** + * Determine whether a log entry exists at the specified array offset. + * Part of the ArrayAccess interface. + * + * @param int $offset + * @return bool + */ + public function offsetExists($offset) { + } + + /** + * Get the ExcimerLogEntry object at the specified array offset. + * + * @param int $offset + * @return ExcimerLogEntry|null + */ + public function offsetGet($offset) { + } + + /** + * This function is included for compliance with the ArrayAccess interface. + * It raises a warning and does nothing. + * + * @param int $offset + * @param mixed $value + */ + public function offsetSet($offset, $value) { + } + + /** + * This function is included for compliance with the ArrayAccess interface. + * It raises a warning and does nothing. + * + * @param int $offset + */ + public function offsetUnset($offset) { + } +} diff --git a/config/config.sample.php b/config/config.sample.php index 97388db99f2..ddc0deb79b9 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -1183,6 +1183,65 @@ $CONFIG = [ 'profiler' => false, /** + * Enable profiling for individual requests if profiling single requests is enabled or the secret is passed. + * This requires the excimer extension to be installed. Be careful with this, as it can generate a lot of data. + * + * The profile data will be stored as a json file in the profiling.path directory that can be analysed with speedscope. + * + * Defaults to ``false`` + */ +'profiling.request' => false, + +/** + * The rate at which profiling data is collected for individual requests. + * A lower value means more data points but higher overhead. + * + * Defaults to ``0.001`` + */ +'profiling.request.rate' => 0.001, + +/** + * A secret token that can be passed via ?profile_secret=<secret> to enable profiling for a specific request. + * This allows profiling specific requests in production without enabling it globally. + * + * No default value. + */ +'profiling.secret' => '', + +/** + * Enable sampling-based profiling. This collects profiling data periodically rather than per-request. + * This requires the excimer extension to be installed. Be careful with this, as it can generate a lot of data. + * + * The profile data will be stored as a plain text file in the profiling.path directory that can be analysed with speedscope. + * + * Defaults to ``false`` + */ +'profiling.sample' => false, + +/** + * The rate at which sampling profiling data is collected in seconds. + * A lower value means more frequent samples but higher overhead. + * + * Defaults to ``1`` + */ +'profiling.sample.rate' => 1, + +/** + * How often (in minutes) the sample log files are rotated. + * + * Defaults to ``60`` + */ +'profiling.sample.rotation' => 60, + +/** + * The directory where profiling data is stored. + * + * Note that this directory must be writable by the web server user and will not be cleaned up automatically. + */ +'profiling.path' => '/tmp', + + +/** * Alternate Code Locations * * Some Nextcloud code may be stored in alternate locations. diff --git a/lib/base.php b/lib/base.php index 0ed282eff00..aa463e206a3 100644 --- a/lib/base.php +++ b/lib/base.php @@ -7,6 +7,7 @@ declare(strict_types=1); * SPDX-License-Identifier: AGPL-3.0-only */ use OC\Encryption\HookManager; +use OC\Profiler\BuiltInProfiler; use OC\Share20\GroupDeletedListener; use OC\Share20\Hooks; use OC\Share20\UserDeletedListener; @@ -14,6 +15,7 @@ use OC\Share20\UserRemovedListener; use OCP\EventDispatcher\IEventDispatcher; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserRemovedEvent; +use OCP\IConfig; use OCP\ILogger; use OCP\IRequest; use OCP\IURLGenerator; @@ -126,7 +128,6 @@ class OC { } } - if (OC::$CLI) { OC::$WEBROOT = self::$config->getValue('overwritewebroot', ''); } else { @@ -522,7 +523,7 @@ class OC { * We use an additional cookie since we want to protect logout CSRF and * also we can't directly interfere with PHP's session mechanism. */ - private static function performSameSiteCookieProtection(\OCP\IConfig $config): void { + private static function performSameSiteCookieProtection(IConfig $config): void { $request = Server::get(IRequest::class); // Some user agents are notorious and don't really properly follow HTTP @@ -635,6 +636,16 @@ class OC { self::$server = new \OC\Server(\OC::$WEBROOT, self::$config); self::$server->boot(); + try { + $profiler = new BuiltInProfiler( + Server::get(IConfig::class), + Server::get(IRequest::class), + ); + $profiler->start(); + } catch (\Throwable $e) { + logger('core')->error('Failed to start profiler: ' . $e->getMessage(), ['app' => 'base']); + } + if (self::$CLI && in_array('--' . \OCP\Console\ReservedOptions::DEBUG_LOG, $_SERVER['argv'])) { \OC\Core\Listener\BeforeMessageLoggedEventListener::setup(); } @@ -654,7 +665,7 @@ class OC { // initialize intl fallback if necessary OC_Util::isSetLocaleWorking(); - $config = Server::get(\OCP\IConfig::class); + $config = Server::get(IConfig::class); if (!defined('PHPUNIT_RUN')) { $errorHandler = new OC\Log\ErrorHandler( \OCP\Server::get(\Psr\Log\LoggerInterface::class), diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 670a719c9fb..1f3d9d3813b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1852,6 +1852,7 @@ return array( 'OC\\Profile\\Actions\\WebsiteAction' => $baseDir . '/lib/private/Profile/Actions/WebsiteAction.php', 'OC\\Profile\\ProfileManager' => $baseDir . '/lib/private/Profile/ProfileManager.php', 'OC\\Profile\\TProfileHelper' => $baseDir . '/lib/private/Profile/TProfileHelper.php', + 'OC\\Profiler\\BuiltInProfiler' => $baseDir . '/lib/private/Profiler/BuiltInProfiler.php', 'OC\\Profiler\\FileProfilerStorage' => $baseDir . '/lib/private/Profiler/FileProfilerStorage.php', 'OC\\Profiler\\Profile' => $baseDir . '/lib/private/Profiler/Profile.php', 'OC\\Profiler\\Profiler' => $baseDir . '/lib/private/Profiler/Profiler.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index c15d9d5b53c..8c87d90156d 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1893,6 +1893,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Profile\\Actions\\WebsiteAction' => __DIR__ . '/../../..' . '/lib/private/Profile/Actions/WebsiteAction.php', 'OC\\Profile\\ProfileManager' => __DIR__ . '/../../..' . '/lib/private/Profile/ProfileManager.php', 'OC\\Profile\\TProfileHelper' => __DIR__ . '/../../..' . '/lib/private/Profile/TProfileHelper.php', + 'OC\\Profiler\\BuiltInProfiler' => __DIR__ . '/../../..' . '/lib/private/Profiler/BuiltInProfiler.php', 'OC\\Profiler\\FileProfilerStorage' => __DIR__ . '/../../..' . '/lib/private/Profiler/FileProfilerStorage.php', 'OC\\Profiler\\Profile' => __DIR__ . '/../../..' . '/lib/private/Profiler/Profile.php', 'OC\\Profiler\\Profiler' => __DIR__ . '/../../..' . '/lib/private/Profiler/Profiler.php', diff --git a/lib/private/Profiler/BuiltInProfiler.php b/lib/private/Profiler/BuiltInProfiler.php new file mode 100644 index 00000000000..0a62365e901 --- /dev/null +++ b/lib/private/Profiler/BuiltInProfiler.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OC\Profiler; + +use DateTime; +use OCP\IConfig; +use OCP\IRequest; + +class BuiltInProfiler { + private \ExcimerProfiler $excimer; + + public function __construct( + private IConfig $config, + private IRequest $request, + ) { + } + + public function start(): void { + if (!extension_loaded('excimer')) { + return; + } + + $shouldProfileSingleRequest = $this->shouldProfileSingleRequest(); + $shouldSample = $this->config->getSystemValueBool('profiling.sample') && !$shouldProfileSingleRequest; + + + if (!$shouldProfileSingleRequest && !$shouldSample) { + return; + } + + $requestRate = $this->config->getSystemValue('profiling.request.rate', 0.001); + $sampleRate = $this->config->getSystemValue('profiling.sample.rate', 1.0); + $eventType = $this->config->getSystemValue('profiling.event_type', EXCIMER_REAL); + + + $this->excimer = new \ExcimerProfiler(); + $this->excimer->setPeriod($shouldProfileSingleRequest ? $requestRate : $sampleRate); + $this->excimer->setEventType($eventType); + $this->excimer->setMaxDepth(250); + + if ($shouldSample) { + $this->excimer->setFlushCallback([$this, 'handleSampleFlush'], 1); + } + + $this->excimer->start(); + register_shutdown_function([$this, 'handleShutdown']); + } + + public function handleSampleFlush(\ExcimerLog $log): void { + file_put_contents($this->getSampleFilename(), $log->formatCollapsed(), FILE_APPEND); + } + + public function handleShutdown(): void { + $this->excimer->stop(); + + if (!$this->shouldProfileSingleRequest()) { + $this->excimer->flush(); + return; + } + + $request = \OCP\Server::get(IRequest::class); + $data = $this->excimer->getLog()->getSpeedscopeData(); + + $data['profiles'][0]['name'] = $request->getMethod() . ' ' . $request->getRequestUri() . ' ' . $request->getId(); + + file_put_contents($this->getProfileFilename(), json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + private function shouldProfileSingleRequest(): bool { + $shouldProfileSingleRequest = $this->config->getSystemValueBool('profiling.request', false); + $profileSecret = $this->config->getSystemValueString('profiling.secret', ''); + $secretParam = $this->request->getParam('profile_secret') ?? null; + return $shouldProfileSingleRequest || (!empty($profileSecret) && $profileSecret === $secretParam); + } + + private function getSampleFilename(): string { + $profilePath = $this->config->getSystemValueString('profiling.path', '/tmp'); + $sampleRotation = $this->config->getSystemValueInt('profiling.sample.rotation', 60); + $timestamp = floor(time() / ($sampleRotation * 60)) * ($sampleRotation * 60); + $sampleName = date('Y-m-d_Hi', (int)$timestamp); + return $profilePath . '/sample-' . $sampleName . '.log'; + } + + private function getProfileFilename(): string { + $profilePath = $this->config->getSystemValueString('profiling.path', '/tmp'); + $requestId = $this->request->getId(); + return $profilePath . '/profile-' . (new DateTime)->format('Y-m-d_His_v') . '-' . $requestId . '.json'; + } +} diff --git a/psalm.xml b/psalm.xml index 5952ac50463..0d4878d5c7a 100644 --- a/psalm.xml +++ b/psalm.xml @@ -74,6 +74,7 @@ </extraFiles> <stubs> <file name="build/stubs/apcu.php"/> + <file name="build/stubs/excimer.php"/> <file name="build/stubs/gd.php"/> <file name="build/stubs/imagick.php"/> <file name="build/stubs/intl.php"/> |