FileProfilerStorage.php 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. <?php
  2. declare(strict_types = 1);
  3. /**
  4. * @copyright 2022 Carl Schwan <carl@carlschwan.eu>
  5. *
  6. * @author Carl Schwan <carl@carlschwan.eu>
  7. * @author Alexandre Salomé <alexandre.salome@gmail.com>
  8. *
  9. * @license AGPL-3.0-or-later AND MIT
  10. *
  11. * This program is free software: you can redistribute it and/or modify
  12. * it under the terms of the GNU Affero General Public License as
  13. * published by the Free Software Foundation, either version 3 of the
  14. * License, or (at your option) any later version.
  15. *
  16. * This program is distributed in the hope that it will be useful,
  17. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. * GNU Affero General Public License for more details.
  20. *
  21. * You should have received a copy of the GNU Affero General Public License
  22. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  23. *
  24. */
  25. namespace OC\Profiler;
  26. use OCP\Profiler\IProfile;
  27. /**
  28. * Storage for profiler using files.
  29. */
  30. class FileProfilerStorage {
  31. // Folder where profiler data are stored.
  32. private string $folder;
  33. /**
  34. * Constructs the file storage using a "dsn-like" path.
  35. *
  36. * Example : "file:/path/to/the/storage/folder"
  37. *
  38. * @throws \RuntimeException
  39. */
  40. public function __construct(string $folder) {
  41. $this->folder = $folder;
  42. if (!is_dir($this->folder) && false === @mkdir($this->folder, 0777, true) && !is_dir($this->folder)) {
  43. throw new \RuntimeException(sprintf('Unable to create the storage directory (%s).', $this->folder));
  44. }
  45. }
  46. public function find(?string $url, ?int $limit, ?string $method, int $start = null, int $end = null, string $statusCode = null): array {
  47. $file = $this->getIndexFilename();
  48. if (!file_exists($file)) {
  49. return [];
  50. }
  51. $file = fopen($file, 'r');
  52. fseek($file, 0, \SEEK_END);
  53. $result = [];
  54. while (\count($result) < $limit && $line = $this->readLineFromFile($file)) {
  55. $values = str_getcsv($line);
  56. [$csvToken, $csvMethod, $csvUrl, $csvTime, $csvParent, $csvStatusCode] = $values;
  57. $csvTime = (int) $csvTime;
  58. if ($url && false === strpos($csvUrl, $url) || $method && false === strpos($csvMethod, $method) || $statusCode && false === strpos($csvStatusCode, $statusCode)) {
  59. continue;
  60. }
  61. if (!empty($start) && $csvTime < $start) {
  62. continue;
  63. }
  64. if (!empty($end) && $csvTime > $end) {
  65. continue;
  66. }
  67. $result[$csvToken] = [
  68. 'token' => $csvToken,
  69. 'method' => $csvMethod,
  70. 'url' => $csvUrl,
  71. 'time' => $csvTime,
  72. 'parent' => $csvParent,
  73. 'status_code' => $csvStatusCode,
  74. ];
  75. }
  76. fclose($file);
  77. return array_values($result);
  78. }
  79. public function purge(): void {
  80. $flags = \FilesystemIterator::SKIP_DOTS;
  81. $iterator = new \RecursiveDirectoryIterator($this->folder, $flags);
  82. $iterator = new \RecursiveIteratorIterator($iterator, \RecursiveIteratorIterator::CHILD_FIRST);
  83. foreach ($iterator as $file) {
  84. $file = $file->getPathInfo();
  85. if (is_file($file)) {
  86. unlink($file);
  87. } else {
  88. rmdir($file);
  89. }
  90. }
  91. }
  92. public function read(string $token): ?IProfile {
  93. if (!$token || !file_exists($file = $this->getFilename($token))) {
  94. return null;
  95. }
  96. if (\function_exists('gzcompress')) {
  97. $file = 'compress.zlib://'.$file;
  98. }
  99. return $this->createProfileFromData($token, unserialize(file_get_contents($file)));
  100. }
  101. /**
  102. * @throws \RuntimeException
  103. */
  104. public function write(IProfile $profile): bool {
  105. $file = $this->getFilename($profile->getToken());
  106. $profileIndexed = is_file($file);
  107. if (!$profileIndexed) {
  108. // Create directory
  109. $dir = \dirname($file);
  110. if (!is_dir($dir) && false === @mkdir($dir, 0777, true) && !is_dir($dir)) {
  111. throw new \RuntimeException(sprintf('Unable to create the storage directory (%s).', $dir));
  112. }
  113. }
  114. $profileToken = $profile->getToken();
  115. // when there are errors in sub-requests, the parent and/or children tokens
  116. // may equal the profile token, resulting in infinite loops
  117. $parentToken = $profile->getParentToken() !== $profileToken ? $profile->getParentToken() : null;
  118. $childrenToken = array_filter(array_map(function (IProfile $p) use ($profileToken) {
  119. return $profileToken !== $p->getToken() ? $p->getToken() : null;
  120. }, $profile->getChildren()));
  121. // Store profile
  122. $data = [
  123. 'token' => $profileToken,
  124. 'parent' => $parentToken,
  125. 'children' => $childrenToken,
  126. 'data' => $profile->getCollectors(),
  127. 'method' => $profile->getMethod(),
  128. 'url' => $profile->getUrl(),
  129. 'time' => $profile->getTime(),
  130. 'status_code' => $profile->getStatusCode(),
  131. ];
  132. $context = stream_context_create();
  133. if (\function_exists('gzcompress')) {
  134. $file = 'compress.zlib://'.$file;
  135. stream_context_set_option($context, 'zlib', 'level', 3);
  136. }
  137. if (false === file_put_contents($file, serialize($data), 0, $context)) {
  138. return false;
  139. }
  140. if (!$profileIndexed) {
  141. // Add to index
  142. if (false === $file = fopen($this->getIndexFilename(), 'a')) {
  143. return false;
  144. }
  145. fputcsv($file, [
  146. $profile->getToken(),
  147. $profile->getMethod(),
  148. $profile->getUrl(),
  149. $profile->getTime(),
  150. $profile->getParentToken(),
  151. $profile->getStatusCode(),
  152. ]);
  153. fclose($file);
  154. }
  155. return true;
  156. }
  157. /**
  158. * Gets filename to store data, associated to the token.
  159. *
  160. * @return string The profile filename
  161. */
  162. protected function getFilename(string $token): string {
  163. // Uses 4 last characters, because first are mostly the same.
  164. $folderA = substr($token, -2, 2);
  165. $folderB = substr($token, -4, 2);
  166. return $this->folder.'/'.$folderA.'/'.$folderB.'/'.$token;
  167. }
  168. /**
  169. * Gets the index filename.
  170. *
  171. * @return string The index filename
  172. */
  173. protected function getIndexFilename(): string {
  174. return $this->folder.'/index.csv';
  175. }
  176. /**
  177. * Reads a line in the file, backward.
  178. *
  179. * This function automatically skips the empty lines and do not include the line return in result value.
  180. *
  181. * @param resource $file The file resource, with the pointer placed at the end of the line to read
  182. *
  183. * @return ?string A string representing the line or null if beginning of file is reached
  184. */
  185. protected function readLineFromFile($file): ?string {
  186. $line = '';
  187. $position = ftell($file);
  188. if (0 === $position) {
  189. return null;
  190. }
  191. while (true) {
  192. $chunkSize = min($position, 1024);
  193. $position -= $chunkSize;
  194. fseek($file, $position);
  195. if (0 === $chunkSize) {
  196. // bof reached
  197. break;
  198. }
  199. $buffer = fread($file, $chunkSize);
  200. if (false === ($upTo = strrpos($buffer, "\n"))) {
  201. $line = $buffer.$line;
  202. continue;
  203. }
  204. $position += $upTo;
  205. $line = substr($buffer, $upTo + 1).$line;
  206. fseek($file, max(0, $position), \SEEK_SET);
  207. if ('' !== $line) {
  208. break;
  209. }
  210. }
  211. return '' === $line ? null : $line;
  212. }
  213. protected function createProfileFromData(string $token, array $data, IProfile $parent = null): IProfile {
  214. $profile = new Profile($token);
  215. $profile->setMethod($data['method']);
  216. $profile->setUrl($data['url']);
  217. $profile->setTime($data['time']);
  218. $profile->setStatusCode($data['status_code']);
  219. $profile->setCollectors($data['data']);
  220. if (!$parent && $data['parent']) {
  221. $parent = $this->read($data['parent']);
  222. }
  223. if ($parent) {
  224. $profile->setParent($parent);
  225. }
  226. foreach ($data['children'] as $token) {
  227. if (!$token || !file_exists($file = $this->getFilename($token))) {
  228. continue;
  229. }
  230. if (\function_exists('gzcompress')) {
  231. $file = 'compress.zlib://'.$file;
  232. }
  233. $profile->addChild($this->createProfileFromData($token, unserialize(file_get_contents($file)), $profile));
  234. }
  235. return $profile;
  236. }
  237. }