├── ext_conf_template.txt ├── Configuration ├── RequestMiddlewares.php └── Services.yaml ├── Classes ├── Event │ └── MetricsCollectingEvent.php ├── Extension.php ├── EventListener │ └── Metrics │ │ ├── Php.php │ │ ├── Typo3VersionInfo.php │ │ ├── Typo3Scheduler.php │ │ ├── Database.php │ │ └── Content.php ├── Service │ └── MetricsCollector.php ├── Middleware │ └── MetricsHandler.php └── Storage │ └── CachingFrameworkStorage.php ├── ext_emconf.php ├── ext_localconf.php ├── Resources └── Private │ └── Libs │ └── Build │ └── composer.json ├── composer.json ├── README.md ├── rector.php ├── .github └── workflows │ └── publish.yml └── .gitignore /ext_conf_template.txt: -------------------------------------------------------------------------------- 1 | # cat=Prometheus; type=string; label=Bearer authorization token to authentication Prometheus scraping requests 2 | authToken = 3 | -------------------------------------------------------------------------------- /Configuration/RequestMiddlewares.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'mfd/prometheus/metrics-middleware' => [ 8 | 'target' => MetricsHandler::class, 9 | 'before' => [ 10 | 'typo3/cms-frontend/site', 11 | ], 12 | ], 13 | ], 14 | ]; 15 | -------------------------------------------------------------------------------- /Classes/Event/MetricsCollectingEvent.php: -------------------------------------------------------------------------------- 1 | registry; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ext_emconf.php: -------------------------------------------------------------------------------- 1 | 'TYPO3 Prometheus Metrics', 5 | 'description' => 'Exports Prometheus metrics for TYPO3 instances', 6 | 'category' => 'misc', 7 | 'author' => 'Christian Spoo', 8 | 'author_email' => 'christian.spoo@marketing-factory.de', 9 | 'state' => 'beta', 10 | 'version' => '2.0.0', 11 | 'constraints' => [ 12 | 'depends' => [ 13 | 'typo3' => '12.4.0-13.9.99', 14 | ], 15 | 'conflicts' => [], 16 | 'suggests' => [], 17 | ], 18 | ]; 19 | -------------------------------------------------------------------------------- /ext_localconf.php: -------------------------------------------------------------------------------- 1 | getRegistry()->getOrRegisterGauge( 15 | 'typo3', 16 | 'memory_usage_bytes', 17 | 'Current memory usage in bytes' 18 | ); 19 | $gauge->set(memory_get_usage(true)); 20 | 21 | $gauge = $event->getRegistry()->getOrRegisterGauge( 22 | 'typo3', 23 | 'memory_peak_usage_bytes', 24 | 'Peak memory usage in bytes' 25 | ); 26 | $gauge->set(memory_get_peak_usage(true)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Classes/Service/MetricsCollector.php: -------------------------------------------------------------------------------- 1 | eventDispatcher->dispatch(new MetricsCollectingEvent($this->registry)); 23 | 24 | // Render metrics in the Prometheus text format 25 | // @todo remove braces when PHPStan 2.1 is used 26 | return (new RenderTextFormat())->render($this->registry->getMetricFamilySamples()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Configuration/Services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | Mfd\Prometheus\: 8 | resource: '../Classes/*' 9 | exclude: '../Classes/Domain/Model/*' 10 | 11 | # Register the cache frontends 12 | cache.prometheus: 13 | class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface 14 | factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] 15 | arguments: ['prometheus'] 16 | cache.prometheus_storage: 17 | class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface 18 | factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] 19 | arguments: ['prometheus_storage'] 20 | 21 | Mfd\Prometheus\EventListener\: 22 | resource: '../Classes/EventListener' 23 | tags: 24 | - name: event.listener 25 | 26 | Prometheus\RegistryInterface: 27 | class: Prometheus\CollectorRegistry 28 | arguments: ['@Prometheus\Storage\InMemory'] 29 | Prometheus\Storage\InMemory: ~ 30 | -------------------------------------------------------------------------------- /Classes/EventListener/Metrics/Typo3VersionInfo.php: -------------------------------------------------------------------------------- 1 | getRegistry()->getOrRegisterGauge( 20 | 'typo3', 21 | 'version_info', 22 | 'TYPO3 version information', 23 | ['version', 'application_context'] 24 | ); 25 | 26 | $gauge->set(1, [ 27 | $this->typo3Version->getVersion(), 28 | Environment::getContext()->__toString() 29 | ]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mfd/typo3-prometheus", 3 | "description": "Exports Prometheus metrics for TYPO3 instances", 4 | "type": "typo3-cms-extension", 5 | "license": [ 6 | "GPL-3.0-or-later" 7 | ], 8 | "authors": [ 9 | { 10 | "name": "Christian Spoo", 11 | "role": "Developer", 12 | "email": "christian.spoo@marketing-factory.de" 13 | } 14 | ], 15 | "require": { 16 | "php": "^8.1", 17 | "ext-json": "*", 18 | "typo3/cms-core": "^12.4 || ^13.4", 19 | "promphp/prometheus_client_php": "^2.7" 20 | }, 21 | "require-dev": { 22 | "roave/security-advisories": "dev-latest", 23 | "ssch/typo3-rector": "^3.6" 24 | }, 25 | "extra": { 26 | "typo3/cms": { 27 | "extension-key": "prometheus" 28 | } 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "Mfd\\Prometheus\\": "Classes/" 33 | } 34 | }, 35 | "replace": { 36 | "mfc/prometheus": "*" 37 | }, 38 | "config": { 39 | "allow-plugins": { 40 | "typo3/cms-composer-installers": true, 41 | "typo3/class-alias-loader": true 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Classes/EventListener/Metrics/Typo3Scheduler.php: -------------------------------------------------------------------------------- 1 | getRegistry()->getOrRegisterGauge( 19 | 'typo3', 20 | 'scheduler_last_run_start', 21 | 'Start date of the of the TYPO3 scheduler\'s last run' 22 | ); 23 | $lastRunEndGauge = $event->getRegistry()->getOrRegisterGauge( 24 | 'typo3', 25 | 'scheduler_last_run_end', 26 | 'End date of the of the TYPO3 scheduler\'s last run' 27 | ); 28 | $lastRunTypeGauge = $event->getRegistry()->getOrRegisterGauge( 29 | 'typo3', 30 | 'scheduler_last_run_type', 31 | 'Type of the of the TYPO3 scheduler\'s last run (0=automatically, 1=manual)' 32 | ); 33 | 34 | $lastRunInfo = $this->registry->get('tx_scheduler', 'lastRun', []); 35 | 36 | $lastRunStartGauge->set($lastRunInfo['start'] ?? 0); 37 | $lastRunEndGauge->set($lastRunInfo['end'] ?? 0); 38 | $lastRunTypeGauge->set($lastRunInfo['type'] === 'manual' ? 1 : 0); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TYPO3 Prometheus Metrics Extension 2 | 3 | This extension provides a Prometheus metrics endpoint for TYPO3 instances. It exposes various TYPO3-specific metrics that can be scraped by Prometheus to monitor the health and performance of your TYPO3 site. 4 | 5 | ## Installation 6 | 7 | 1. Install the extension via composer: 8 | ``` 9 | composer require mfd/typo3-prometheus 10 | ``` 11 | 12 | 2. Activate the extension in the Extension Manager or via command line: 13 | ``` 14 | vendor/bin/typo3 extension:activate prometheus 15 | ``` 16 | 17 | ## Usage 18 | 19 | After installation, a metrics endpoint will be available at `/metrics`. This endpoint will return Prometheus-compatible metrics in the OpenMetrics text format. 20 | 21 | ## Available Metrics 22 | 23 | The following metrics are currently exposed: 24 | 25 | - `typo3_version_info`: Information about the TYPO3 version and application context 26 | - `typo3_memory_usage_bytes`: Current memory usage in bytes 27 | - `typo3_memory_peak_usage_bytes`: Peak memory usage in bytes 28 | - `typo3_pages_total`: Total number of pages in the system 29 | - `typo3_content_elements_total`: Total number of content elements 30 | 31 | ## Configuration 32 | 33 | No additional configuration is needed. The extension works out of the box. 34 | 35 | ## Security Considerations 36 | 37 | It's recommended to restrict access to the `/metrics` endpoint in production environments, as it may expose sensitive information. You can use your web server's configuration to restrict access based on IP addresses or authentication. 38 | 39 | ## License 40 | 41 | This extension is licensed under the terms of the GNU General Public License version 3 or later. 42 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withConfiguredRule(ExtEmConfRector::class, [ 14 | ExtEmConfRector::ADDITIONAL_VALUES_TO_BE_REMOVED => [] 15 | ]) 16 | ->withPaths([ 17 | __DIR__ . '/Classes', 18 | __DIR__ . '/Configuration', 19 | ]) 20 | // uncomment to reach your current PHP version 21 | ->withPhpSets() 22 | ->withPreparedSets(codeQuality: true) 23 | ->withAttributesSets(symfony: true, jms: true) 24 | ->withSets([ 25 | Typo3LevelSetList::UP_TO_TYPO3_12, 26 | ]) 27 | # To have a better analysis from PHPStan, we teach it here some more things 28 | ->withPHPStanConfigs([ 29 | Typo3Option::PHPSTAN_FOR_RECTOR_PATH 30 | ]) 31 | ->withRules([ 32 | ConvertImplicitVariablesToExplicitGlobalsRector::class, 33 | ]) 34 | # If you use importNames(), you should consider excluding some TYPO3 files. 35 | ->withSkip([ 36 | // @see https://github.com/sabbelasichon/typo3-rector/issues/2536 37 | __DIR__ . '/**/Configuration/ExtensionBuilder/*', 38 | __DIR__ . '/**/node_modules/*', 39 | NameImportingPostRector::class => [ 40 | 'ext_localconf.php', 41 | 'ext_tables.php', 42 | 'ClassAliasMap.php', 43 | __DIR__ . '/**/Configuration/*.php', 44 | __DIR__ . '/**/Configuration/**/*.php', 45 | ] 46 | ]); 47 | -------------------------------------------------------------------------------- /Classes/Middleware/MetricsHandler.php: -------------------------------------------------------------------------------- 1 | getUri()->getPath(); 31 | 32 | if ($path !== self::METRICS_PATH) { 33 | return $handler->handle($request); 34 | } 35 | 36 | $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class); 37 | $authToken = (string)$extensionConfiguration->get('prometheus', 'authToken'); 38 | 39 | if ($authToken !== '' && $request->getHeaderLine('Authorization') !== 'Bearer ' . $authToken) { 40 | return new HtmlResponse( 41 | 'Unauthorized', 42 | 401, 43 | [ 44 | 'Content-Type' => 'text/plain; version=0.0.4', 45 | 'WWW-Authenticate' => 'Bearer', 46 | ] 47 | ); 48 | } 49 | 50 | // This is a metrics request, gather metrics and return them 51 | $metrics = $this->metricsCollector->collectMetrics(); 52 | 53 | $body = new Stream('php://temp', 'rw'); 54 | $body->write($metrics); 55 | $body->rewind(); 56 | 57 | return new Response( 58 | $body, 59 | 200, 60 | [ 61 | 'Content-Type' => 'text/plain; version=0.0.4', 62 | 'Cache-Control' => 'no-cache, private, no-store, must-revalidate', 63 | ] 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | name: Publish new version to TER 11 | if: startsWith(github.ref, 'refs/tags/') 12 | runs-on: ubuntu-latest 13 | env: 14 | TYPO3_EXTENSION_KEY: prometheus 15 | TYPO3_API_TOKEN: ${{ secrets.TYPO3_API_TOKEN }} 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | 21 | - name: Check tag 22 | run: | 23 | if ! [[ ${{ github.ref }} =~ ^refs/tags/[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$ ]]; then 24 | exit 1 25 | fi 26 | - name: Get version 27 | id: get-version 28 | run: echo "version=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT 29 | 30 | - name: Get comment 31 | id: get-comment 32 | run: echo "comment=${{ github.event.release.body }}" >> $GITHUB_OUTPUT 33 | 34 | 35 | 36 | - name: Setup PHP 37 | uses: shivammathur/setup-php@v2 38 | with: 39 | php-version: 8.4 40 | extensions: intl, mbstring, json, zip, curl 41 | tools: composer:v2, typo3/tailor, clue/phar-composer 42 | 43 | - name: Download vendor files 44 | run: | 45 | composer install -d Resources/Private/Libs/Build 46 | composer global exec phar-composer build Resources/Private/Libs/Build Resources/Private/Libs/vendors.phar 47 | - name: Include Phar file 48 | run: echo "Mfd\Prometheus\Extension::loadVendorLibraries();" >> ext_localconf.php 49 | 50 | 51 | - name: Publish to TER 52 | run: | 53 | php ~/.composer/vendor/bin/tailor set-version "${{ steps.get-version.outputs.version }}" 54 | php ~/.composer/vendor/bin/tailor ter:publish --comment "${{ steps.get-comment.outputs.comment }}" \ 55 | ${{ steps.get-version.outputs.version }} 56 | continue-on-error: true 57 | 58 | - name: Create Standalone zip 59 | run: | 60 | php ~/.composer/vendor/bin/tailor set-version "${{ steps.get-version.outputs.version }}" 61 | php ~/.composer/vendor/bin/tailor create-artefact \ 62 | ${{ steps.get-version.outputs.version }} ${{ env.TYPO3_EXTENSION_KEY }} 63 | 64 | - name: upload artifacts in the same step 65 | uses: softprops/action-gh-release@v2 66 | if: ${{startsWith(github.ref, 'refs/tags/') }} 67 | with: 68 | name: "[RELEASE] ${{ steps.get-version.outputs.version }}" 69 | body: "${{ steps.get-comment.outputs.comment }}" 70 | generate_release_notes: true 71 | files: | 72 | tailor-version-artefact/${{ env.TYPO3_EXTENSION_KEY }}_${{ steps.get-version.outputs.version }}.zip 73 | fail_on_unmatched_files: true 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/jetbrains+all,composer 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=jetbrains+all,composer 3 | 4 | ### Composer ### 5 | composer.phar 6 | /vendor/ 7 | 8 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control 9 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 10 | composer.lock 11 | 12 | ### JetBrains+all ### 13 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 14 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 15 | 16 | # User-specific stuff 17 | .idea/**/workspace.xml 18 | .idea/**/tasks.xml 19 | .idea/**/usage.statistics.xml 20 | .idea/**/dictionaries 21 | .idea/**/shelf 22 | 23 | # AWS User-specific 24 | .idea/**/aws.xml 25 | 26 | # Generated files 27 | .idea/**/contentModel.xml 28 | 29 | # Sensitive or high-churn files 30 | .idea/**/dataSources/ 31 | .idea/**/dataSources.ids 32 | .idea/**/dataSources.local.xml 33 | .idea/**/sqlDataSources.xml 34 | .idea/**/dynamic.xml 35 | .idea/**/uiDesigner.xml 36 | .idea/**/dbnavigator.xml 37 | 38 | # Gradle 39 | .idea/**/gradle.xml 40 | .idea/**/libraries 41 | 42 | # Gradle and Maven with auto-import 43 | # When using Gradle or Maven with auto-import, you should exclude module files, 44 | # since they will be recreated, and may cause churn. Uncomment if using 45 | # auto-import. 46 | # .idea/artifacts 47 | # .idea/compiler.xml 48 | # .idea/jarRepositories.xml 49 | # .idea/modules.xml 50 | # .idea/*.iml 51 | # .idea/modules 52 | # *.iml 53 | # *.ipr 54 | 55 | # CMake 56 | cmake-build-*/ 57 | 58 | # Mongo Explorer plugin 59 | .idea/**/mongoSettings.xml 60 | 61 | # File-based project format 62 | *.iws 63 | 64 | # IntelliJ 65 | out/ 66 | 67 | # mpeltonen/sbt-idea plugin 68 | .idea_modules/ 69 | 70 | # JIRA plugin 71 | atlassian-ide-plugin.xml 72 | 73 | # Cursive Clojure plugin 74 | .idea/replstate.xml 75 | 76 | # SonarLint plugin 77 | .idea/sonarlint/ 78 | 79 | # Crashlytics plugin (for Android Studio and IntelliJ) 80 | com_crashlytics_export_strings.xml 81 | crashlytics.properties 82 | crashlytics-build.properties 83 | fabric.properties 84 | 85 | # Editor-based Rest Client 86 | .idea/httpRequests 87 | 88 | # Android studio 3.1+ serialized cache file 89 | .idea/caches/build_file_checksums.ser 90 | 91 | ### JetBrains+all Patch ### 92 | # Ignore everything but code style settings and run configurations 93 | # that are supposed to be shared within teams. 94 | 95 | .idea/* 96 | 97 | !.idea/codeStyles 98 | !.idea/runConfigurations 99 | 100 | # End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,composer 101 | 102 | public/ 103 | -------------------------------------------------------------------------------- /Classes/EventListener/Metrics/Database.php: -------------------------------------------------------------------------------- 1 | getAllTables(); 26 | 27 | $rowGauge = $event->getRegistry()->getOrRegisterGauge( 28 | 'typo3', 29 | 'database_rows', 30 | 'Number of rows by database table', 31 | ['table'] 32 | ); 33 | $deletedGauge = $event->getRegistry()->getOrRegisterGauge( 34 | 'typo3', 35 | 'database_rows_deleted', 36 | 'Number of deleted rows by database table', 37 | ['table'] 38 | ); 39 | 40 | foreach ($tables as $tableName) { 41 | $rowGauge->set($this->fetchTableRawCount($tableName), ['table' => $tableName]); 42 | $deletedGauge->set($this->fetchTableDeletedCount($tableName), ['table' => $tableName]); 43 | } 44 | } 45 | 46 | protected function fetchTableRawCount(string $tableName): int 47 | { 48 | $cacheKey = 'table_count_' . $tableName . '-raw'; 49 | $value = $this->cache->get($cacheKey); 50 | 51 | if ($value === false) { 52 | $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); 53 | $queryBuilder->getRestrictions()->removeAll(); 54 | 55 | $count = $queryBuilder 56 | ->count('*') 57 | ->from($tableName) 58 | ->executeQuery() 59 | ->fetchOne(); 60 | 61 | $value = (int)$count; 62 | $this->cache->set($cacheKey, $value, ["table_count"], self::TABLE_COUNT_TTL); 63 | } 64 | 65 | return $value; 66 | } 67 | 68 | protected function fetchTableDeletedCount(string $tableName): int 69 | { 70 | $cacheKey = 'table_count_' . $tableName . '-deleted'; 71 | $value = $this->cache->get($cacheKey); 72 | 73 | if ($value === false) { 74 | try { 75 | $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); 76 | $queryBuilder->getRestrictions()->removeAll(); 77 | 78 | $count = $queryBuilder 79 | ->count('*') 80 | ->from($tableName) 81 | ->where($queryBuilder->expr()->eq('deleted', 1)) 82 | ->executeQuery() 83 | ->fetchOne(); 84 | 85 | $value = (int)$count; 86 | $this->cache->set($cacheKey, $value, ["table_count"], self::TABLE_COUNT_TTL); 87 | } catch (InvalidFieldNameException) { 88 | // Table does not have a deleted column 89 | $value = 0; 90 | $this->cache->set($cacheKey, $value, ["table_count"]); 91 | } 92 | } 93 | 94 | return $value; 95 | } 96 | 97 | private function getAllTables(): array 98 | { 99 | $cacheKey = 'table_names'; 100 | $value = $this->cache->get($cacheKey); 101 | 102 | if ($value === false) { 103 | $schemaManager = $this->connectionPool->getConnectionForTable('pages')->createSchemaManager(); 104 | $value = $schemaManager->listTableNames(); 105 | 106 | $this->cache->set($cacheKey, $value, ["table_names"], self::TABLE_NAMES_TTL); 107 | } 108 | 109 | return $value; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Classes/EventListener/Metrics/Content.php: -------------------------------------------------------------------------------- 1 | fetchPageCount(false); 34 | $gauge = $event->getRegistry()->getOrRegisterGauge( 35 | 'typo3', 36 | 'pages_total', 37 | 'Total number of all pages (including deleted pages)' 38 | ); 39 | $gauge->set($pageCount); 40 | 41 | // Fetch visible page count 42 | $visiblePageCount = $this->fetchPageCount(); 43 | $gauge = $event->getRegistry()->getOrRegisterGauge( 44 | 'typo3', 45 | 'pages_visible', 46 | 'Total number of visible pages' 47 | ); 48 | $gauge->set($visiblePageCount); 49 | 50 | // Fetch content element count 51 | $contentCount = $this->fetchTableCount('tt_content', false); 52 | $gauge = $event->getRegistry()->getOrRegisterGauge( 53 | 'typo3', 54 | 'content_elements_total', 55 | 'Total number of content elements' 56 | ); 57 | $gauge->set($contentCount); 58 | 59 | // Fetch current content element count 60 | $contentCount = $this->fetchTableCount('tt_content'); 61 | $gauge = $event->getRegistry()->getOrRegisterGauge( 62 | 'typo3', 63 | 'content_elements_current', 64 | 'Total number of currently active content elements' 65 | ); 66 | $gauge->set($contentCount); 67 | } 68 | 69 | protected function fetchTableCount(string $tableName, bool $onlyVisible = true): int 70 | { 71 | $cacheKey = 'table_count_' . $tableName . ($onlyVisible ? '-only_visible' : ''); 72 | $value = $this->cache->get($cacheKey); 73 | 74 | if ($value === false || !is_numeric($value)) { 75 | $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); 76 | 77 | 78 | if (!$onlyVisible) { 79 | $queryBuilder->getRestrictions()->removeByType(StartTimeRestriction::class); 80 | $queryBuilder->getRestrictions()->removeByType(EndTimeRestriction::class); 81 | $queryBuilder->getRestrictions()->removeByType(DeletedRestriction::class); 82 | $queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class); 83 | } 84 | 85 | $queryBuilder->getRestrictions()->add(new WorkspaceRestriction()); 86 | 87 | $count = $queryBuilder 88 | ->count('*') 89 | ->from($tableName) 90 | ->executeQuery() 91 | ->fetchOne(); 92 | 93 | $value = (int)$count; 94 | $this->cache->set($cacheKey, $value, ["table_count"], self::COUNT_TTL); 95 | } 96 | 97 | return $value; 98 | } 99 | 100 | private function fetchPageCount(bool $onlyVisible = true): int 101 | { 102 | $cacheKey = 'page_count' . ($onlyVisible ? '-only_visible' : ''); 103 | $value = $this->cache->get($cacheKey); 104 | 105 | if ($value === false || !is_numeric($value)) { 106 | $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); 107 | 108 | if (!$onlyVisible) { 109 | $queryBuilder->getRestrictions()->removeByType(StartTimeRestriction::class); 110 | $queryBuilder->getRestrictions()->removeByType(EndTimeRestriction::class); 111 | $queryBuilder->getRestrictions()->removeByType(DeletedRestriction::class); 112 | $queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class); 113 | } 114 | 115 | $queryBuilder->getRestrictions()->add(new WorkspaceRestriction()); 116 | $queryBuilder->getRestrictions()->add(new DocumentTypeExclusionRestriction([ 117 | PageRepository::DOKTYPE_LINK, 118 | PageRepository::DOKTYPE_SHORTCUT, 119 | PageRepository::DOKTYPE_SPACER, 120 | PageRepository::DOKTYPE_SYSFOLDER, 121 | ])); 122 | 123 | $count = $queryBuilder 124 | ->count('*') 125 | ->from('pages') 126 | ->where( 127 | $queryBuilder->expr()->eq('sys_language_uid', 0) // Default language 128 | ) 129 | ->executeQuery() 130 | ->fetchOne(); 131 | 132 | $value = (int)$count; 133 | $this->cache->set($cacheKey, $value, ['page_count'], self::COUNT_TTL); 134 | } 135 | 136 | return $value; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Classes/Storage/CachingFrameworkStorage.php: -------------------------------------------------------------------------------- 1 | internalCollect( 30 | $this->fetch(Counter::TYPE), 31 | $sortMetrics 32 | ); 33 | $metrics = array_merge( 34 | $metrics, 35 | $this->internalCollect($this->fetch(Gauge::TYPE), $sortMetrics) 36 | ); 37 | $metrics = array_merge( 38 | $metrics, 39 | $this->collectHistograms($this->fetch(Histogram::TYPE)) 40 | ); 41 | return array_merge( 42 | $metrics, 43 | $this->collectSummaries($this->fetch(Summary::TYPE)) 44 | ); 45 | } 46 | 47 | protected function collectHistograms(array $histograms): array 48 | { 49 | $output = []; 50 | foreach ($histograms as $histogram) { 51 | $metaData = $histogram['meta']; 52 | $data = [ 53 | 'name' => $metaData['name'], 54 | 'help' => $metaData['help'], 55 | 'type' => $metaData['type'], 56 | 'labelNames' => $metaData['labelNames'], 57 | 'buckets' => $metaData['buckets'], 58 | ]; 59 | 60 | // Add the Inf bucket so we can compute it later on 61 | $data['buckets'][] = '+Inf'; 62 | 63 | $histogramBuckets = []; 64 | foreach ($histogram['samples'] as $key => $value) { 65 | $parts = explode(':', (string) $key); 66 | $labelValues = $parts[2]; 67 | $bucket = $parts[3]; 68 | // Key by labelValues 69 | $histogramBuckets[$labelValues][$bucket] = $value; 70 | } 71 | 72 | // Compute all buckets 73 | $labels = array_keys($histogramBuckets); 74 | sort($labels); 75 | foreach ($labels as $labelValues) { 76 | $acc = 0; 77 | $decodedLabelValues = $this->decodeLabelValues($labelValues); 78 | foreach ($data['buckets'] as $bucket) { 79 | $bucket = (string)$bucket; 80 | if (!isset($histogramBuckets[$labelValues][$bucket])) { 81 | $data['samples'][] = [ 82 | 'name' => $metaData['name'] . '_bucket', 83 | 'labelNames' => ['le'], 84 | 'labelValues' => array_merge( 85 | $decodedLabelValues, 86 | [$bucket] 87 | ), 88 | 'value' => $acc, 89 | ]; 90 | } else { 91 | $acc += $histogramBuckets[$labelValues][$bucket]; 92 | $data['samples'][] = [ 93 | 'name' => $metaData['name'] . '_' . 'bucket', 94 | 'labelNames' => ['le'], 95 | 'labelValues' => array_merge( 96 | $decodedLabelValues, 97 | [$bucket] 98 | ), 99 | 'value' => $acc, 100 | ]; 101 | } 102 | } 103 | 104 | // Add the count 105 | $data['samples'][] = [ 106 | 'name' => $metaData['name'] . '_count', 107 | 'labelNames' => [], 108 | 'labelValues' => $decodedLabelValues, 109 | 'value' => $acc, 110 | ]; 111 | 112 | // Add the sum 113 | $data['samples'][] = [ 114 | 'name' => $metaData['name'] . '_sum', 115 | 'labelNames' => [], 116 | 'labelValues' => $decodedLabelValues, 117 | 'value' => $histogramBuckets[$labelValues]['sum'], 118 | ]; 119 | } 120 | 121 | $output[] = new MetricFamilySamples($data); 122 | } 123 | 124 | return $output; 125 | } 126 | 127 | protected function collectSummaries(array $summaries): array 128 | { 129 | $math = new Math(); 130 | $output = []; 131 | foreach ($summaries as $metaKey => &$summary) { 132 | $metaData = $summary['meta']; 133 | $data = [ 134 | 'name' => $metaData['name'], 135 | 'help' => $metaData['help'], 136 | 'type' => $metaData['type'], 137 | 'labelNames' => $metaData['labelNames'], 138 | 'maxAgeSeconds' => $metaData['maxAgeSeconds'], 139 | 'quantiles' => $metaData['quantiles'], 140 | 'samples' => [], 141 | ]; 142 | 143 | foreach ($summary['samples'] as $key => $values) { 144 | $parts = explode(':', (string) $key); 145 | $labelValues = $parts[2]; 146 | $decodedLabelValues = $this->decodeLabelValues($labelValues); 147 | 148 | // Remove old data 149 | $values = array_filter( 150 | $values, 151 | static fn(array $value): bool => time() - $value['time'] 152 | <= $data['maxAgeSeconds'] 153 | ); 154 | if (count($values) === 0) { 155 | unset($summary['samples'][$key]); 156 | continue; 157 | } 158 | 159 | // Compute quantiles 160 | usort($values, static fn(array $value1, array $value2) => $value1['value'] <=> $value2['value']); 161 | 162 | foreach ($data['quantiles'] as $quantile) { 163 | $data['samples'][] = [ 164 | 'name' => $metaData['name'], 165 | 'labelNames' => ['quantile'], 166 | 'labelValues' => array_merge( 167 | $decodedLabelValues, 168 | [$quantile] 169 | ), 170 | 'value' => $math->quantile(array_column( 171 | $values, 172 | 'value' 173 | ), $quantile), 174 | ]; 175 | } 176 | 177 | // Add the count 178 | $data['samples'][] = [ 179 | 'name' => $metaData['name'] . '_count', 180 | 'labelNames' => [], 181 | 'labelValues' => $decodedLabelValues, 182 | 'value' => count($values), 183 | ]; 184 | 185 | // Add the sum 186 | $data['samples'][] = [ 187 | 'name' => $metaData['name'] . '_sum', 188 | 'labelNames' => [], 189 | 'labelValues' => $decodedLabelValues, 190 | 'value' => array_sum(array_column($values, 'value')), 191 | ]; 192 | } 193 | 194 | if (count($data['samples']) > 0) { 195 | $output[] = new MetricFamilySamples($data); 196 | } 197 | } 198 | 199 | return $output; 200 | } 201 | 202 | protected function internalCollect( 203 | array $metrics, 204 | bool $sortMetrics = true 205 | ): array { 206 | $result = []; 207 | foreach ($metrics as $metric) { 208 | $metaData = $metric['meta']; 209 | $data = [ 210 | 'name' => $metaData['name'], 211 | 'help' => $metaData['help'], 212 | 'type' => $metaData['type'], 213 | 'labelNames' => $metaData['labelNames'], 214 | 'samples' => [], 215 | ]; 216 | foreach ($metric['samples'] as $key => $value) { 217 | $parts = explode(':', (string) $key); 218 | $labelValues = $parts[2]; 219 | $data['samples'][] = [ 220 | 'name' => $metaData['name'], 221 | 'labelNames' => [], 222 | 'labelValues' => $this->decodeLabelValues($labelValues), 223 | 'value' => $value, 224 | ]; 225 | } 226 | 227 | if ($sortMetrics) { 228 | $this->sortSamples($data['samples']); 229 | } 230 | 231 | $result[] = new MetricFamilySamples($data); 232 | } 233 | 234 | return $result; 235 | } 236 | 237 | public function updateHistogram(array $data): void 238 | { 239 | $histograms = $this->fetch(Histogram::TYPE); 240 | 241 | // Initialize the sum 242 | $metaKey = $this->metaKey($data); 243 | if (array_key_exists($metaKey, $histograms) === false) { 244 | $histograms[$metaKey] = [ 245 | 'meta' => $this->metaData($data), 246 | 'samples' => [], 247 | ]; 248 | } 249 | 250 | $sumKey = $this->histogramBucketValueKey($data, 'sum'); 251 | if (array_key_exists($sumKey, $histograms[$metaKey]['samples']) === false) { 252 | $histograms[$metaKey]['samples'][$sumKey] = 0; 253 | } 254 | 255 | $histograms[$metaKey]['samples'][$sumKey] += $data['value']; 256 | 257 | 258 | $bucketToIncrease = '+Inf'; 259 | foreach ($data['buckets'] as $bucket) { 260 | if ($data['value'] <= $bucket) { 261 | $bucketToIncrease = $bucket; 262 | break; 263 | } 264 | } 265 | 266 | $bucketKey = $this->histogramBucketValueKey($data, $bucketToIncrease); 267 | if (array_key_exists($bucketKey, $histograms[$metaKey]['samples']) 268 | === false 269 | ) { 270 | $histograms[$metaKey]['samples'][$bucketKey] = 0; 271 | } 272 | 273 | $histograms[$metaKey]['samples'][$bucketKey] += 1; 274 | 275 | $this->push(Histogram::TYPE, $histograms); 276 | } 277 | 278 | public function updateSummary(array $data): void 279 | { 280 | $summaries = $this->fetch(Summary::TYPE); 281 | 282 | $metaKey = $this->metaKey($data); 283 | if (array_key_exists($metaKey, $summaries) === false) { 284 | $summaries[$metaKey] = [ 285 | 'meta' => $this->metaData($data), 286 | 'samples' => [], 287 | ]; 288 | } 289 | 290 | $valueKey = $this->valueKey($data); 291 | if (array_key_exists($valueKey, $summaries[$metaKey]['samples']) 292 | === false 293 | ) { 294 | $summaries[$metaKey]['samples'][$valueKey] = []; 295 | } 296 | 297 | $summaries[$metaKey]['samples'][$valueKey][] = [ 298 | 'time' => time(), 299 | 'value' => $data['value'], 300 | ]; 301 | 302 | $this->push(Summary::TYPE, $summaries); 303 | } 304 | 305 | public function updateGauge(array $data): void 306 | { 307 | $gauges = $this->fetch(Gauge::TYPE); 308 | 309 | $metaKey = $this->metaKey($data); 310 | $valueKey = $this->valueKey($data); 311 | if (array_key_exists($metaKey, $gauges) === false) { 312 | $gauges[$metaKey] = [ 313 | 'meta' => $this->metaData($data), 314 | 'samples' => [], 315 | ]; 316 | } 317 | 318 | if (array_key_exists($valueKey, $gauges[$metaKey]['samples']) 319 | === false 320 | ) { 321 | $gauges[$metaKey]['samples'][$valueKey] = 0; 322 | } 323 | 324 | if ($data['command'] === Adapter::COMMAND_SET) { 325 | $gauges[$metaKey]['samples'][$valueKey] = $data['value']; 326 | } else { 327 | $gauges[$metaKey]['samples'][$valueKey] += $data['value']; 328 | } 329 | 330 | $this->push(Gauge::TYPE, $gauges); 331 | } 332 | 333 | public function updateCounter(array $data): void 334 | { 335 | $counters = $this->fetch(Counter::TYPE); 336 | 337 | $metaKey = $this->metaKey($data); 338 | $valueKey = $this->valueKey($data); 339 | if (array_key_exists($metaKey, $counters) === false) { 340 | $counters[$metaKey] = [ 341 | 'meta' => $this->metaData($data), 342 | 'samples' => [], 343 | ]; 344 | } 345 | 346 | if (array_key_exists($valueKey, $counters[$metaKey]['samples']) 347 | === false 348 | ) { 349 | $counters[$metaKey]['samples'][$valueKey] = 0; 350 | } 351 | 352 | if ($data['command'] === Adapter::COMMAND_SET) { 353 | $counters[$metaKey]['samples'][$valueKey] = 0; 354 | } else { 355 | $counters[$metaKey]['samples'][$valueKey] += $data['value']; 356 | } 357 | 358 | $this->push(Counter::TYPE, $counters); 359 | } 360 | 361 | protected function histogramBucketValueKey(array $data, $bucket): string 362 | { 363 | return implode(':', [ 364 | $data['type'], 365 | $data['name'], 366 | $this->encodeLabelValues($data['labelValues']), 367 | $bucket, 368 | ]); 369 | } 370 | 371 | protected function metaKey(array $data): string 372 | { 373 | return implode(':', [ 374 | $data['type'], 375 | $data['name'], 376 | 'meta' 377 | ]); 378 | } 379 | 380 | protected function valueKey(array $data): string 381 | { 382 | return implode(':', [ 383 | $data['type'], 384 | $data['name'], 385 | $this->encodeLabelValues($data['labelValues']), 386 | 'value' 387 | ]); 388 | } 389 | 390 | protected function metaData(array $data): array 391 | { 392 | $metricsMetaData = $data; 393 | unset($metricsMetaData['value'], $metricsMetaData['command'], $metricsMetaData['labelValues']); 394 | return $metricsMetaData; 395 | } 396 | 397 | protected function sortSamples(array &$samples): void 398 | { 399 | usort($samples, static fn($a, $b): int => strcmp( 400 | implode("", $a['labelValues']), 401 | implode("", $b['labelValues']) 402 | )); 403 | } 404 | 405 | protected function encodeLabelValues(array $values): string 406 | { 407 | $json = json_encode($values, JSON_THROW_ON_ERROR); 408 | return base64_encode($json); 409 | } 410 | 411 | protected function decodeLabelValues(string $values): array 412 | { 413 | $json = base64_decode($values, true); 414 | if (false === $json) { 415 | throw new RuntimeException('Cannot base64 decode label values'); 416 | } 417 | 418 | $decodedValues = json_decode($json, true, 512, JSON_THROW_ON_ERROR); 419 | return $decodedValues; 420 | } 421 | 422 | protected function fetch(string $type): array 423 | { 424 | $result = $this->cacheStorage->get($this->cacheKey($type)); 425 | return $result !== false ? $result : []; 426 | } 427 | 428 | protected function push(string $type, array $data): void 429 | { 430 | $this->cacheStorage->set($this->cacheKey($type), $data, lifetime: 30); 431 | } 432 | 433 | protected function cacheKey(string $type): string 434 | { 435 | return static::CACHE_KEY_PREFIX . $type . static::CACHE_KEY_SUFFIX; 436 | } 437 | 438 | public function wipeStorage(): void 439 | { 440 | $this->cacheStorage->remove($this->cacheKey(Counter::TYPE)); 441 | $this->cacheStorage->remove($this->cacheKey(Gauge::TYPE)); 442 | $this->cacheStorage->remove($this->cacheKey(Histogram::TYPE)); 443 | $this->cacheStorage->remove($this->cacheKey(Summary::TYPE)); 444 | } 445 | } 446 | --------------------------------------------------------------------------------