diff --git a/apps/comments/appinfo/info.xml b/apps/comments/appinfo/info.xml index 956a8fe3914..efab11b68d8 100644 --- a/apps/comments/appinfo/info.xml +++ b/apps/comments/appinfo/info.xml @@ -38,6 +38,10 @@ + + OCA\Comments\OpenMetrics\CommentsCountMetric + + OCA\Comments\Collaboration\CommentersSorter diff --git a/apps/comments/composer/composer/autoload_classmap.php b/apps/comments/composer/composer/autoload_classmap.php index 6db5c6a232b..a6cd74eeaaa 100644 --- a/apps/comments/composer/composer/autoload_classmap.php +++ b/apps/comments/composer/composer/autoload_classmap.php @@ -22,5 +22,6 @@ return array( 'OCA\\Comments\\MaxAutoCompleteResultsInitialState' => $baseDir . '/../lib/MaxAutoCompleteResultsInitialState.php', 'OCA\\Comments\\Notification\\Listener' => $baseDir . '/../lib/Notification/Listener.php', 'OCA\\Comments\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', + 'OCA\\Comments\\OpenMetrics\\CommentsCountMetric' => $baseDir . '/../lib/OpenMetrics/CommentsCountMetric.php', 'OCA\\Comments\\Search\\CommentsSearchProvider' => $baseDir . '/../lib/Search/CommentsSearchProvider.php', ); diff --git a/apps/comments/composer/composer/autoload_static.php b/apps/comments/composer/composer/autoload_static.php index 60359abb6d0..6dee6b1fc96 100644 --- a/apps/comments/composer/composer/autoload_static.php +++ b/apps/comments/composer/composer/autoload_static.php @@ -37,6 +37,7 @@ class ComposerStaticInitComments 'OCA\\Comments\\MaxAutoCompleteResultsInitialState' => __DIR__ . '/..' . '/../lib/MaxAutoCompleteResultsInitialState.php', 'OCA\\Comments\\Notification\\Listener' => __DIR__ . '/..' . '/../lib/Notification/Listener.php', 'OCA\\Comments\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', + 'OCA\\Comments\\OpenMetrics\\CommentsCountMetric' => __DIR__ . '/..' . '/../lib/OpenMetrics/CommentsCountMetric.php', 'OCA\\Comments\\Search\\CommentsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/CommentsSearchProvider.php', ); diff --git a/apps/comments/lib/OpenMetrics/CommentsCountMetric.php b/apps/comments/lib/OpenMetrics/CommentsCountMetric.php new file mode 100644 index 00000000000..d0c9233b7c3 --- /dev/null +++ b/apps/comments/lib/OpenMetrics/CommentsCountMetric.php @@ -0,0 +1,52 @@ +connection->getQueryBuilder(); + $result = $qb->select($qb->func()->count()) + ->from('comments') + ->where($qb->expr()->eq('verb', $qb->expr()->literal('comment'))) + ->executeQuery(); + + yield new Metric($result->fetchOne(), [], time()); + } +} diff --git a/apps/files_sharing/appinfo/info.xml b/apps/files_sharing/appinfo/info.xml index 9368225fa24..8eaacf1cb20 100644 --- a/apps/files_sharing/appinfo/info.xml +++ b/apps/files_sharing/appinfo/info.xml @@ -87,4 +87,8 @@ Turning the feature off removes shared files and folders on the server for all s public.php + + + OCA\Files_Sharing\OpenMetrics\SharesCountMetric + diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index 48f197f9bf9..919241be141 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -88,6 +88,7 @@ return array( 'OCA\\Files_Sharing\\MountProvider' => $baseDir . '/../lib/MountProvider.php', 'OCA\\Files_Sharing\\Notification\\Listener' => $baseDir . '/../lib/Notification/Listener.php', 'OCA\\Files_Sharing\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', + 'OCA\\Files_Sharing\\OpenMetrics\\SharesCountMetric' => $baseDir . '/../lib/OpenMetrics/SharesCountMetric.php', 'OCA\\Files_Sharing\\OrphanHelper' => $baseDir . '/../lib/OrphanHelper.php', 'OCA\\Files_Sharing\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', 'OCA\\Files_Sharing\\Scanner' => $baseDir . '/../lib/Scanner.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 110a64fb3ac..6a22d082df1 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -103,6 +103,7 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\MountProvider' => __DIR__ . '/..' . '/../lib/MountProvider.php', 'OCA\\Files_Sharing\\Notification\\Listener' => __DIR__ . '/..' . '/../lib/Notification/Listener.php', 'OCA\\Files_Sharing\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', + 'OCA\\Files_Sharing\\OpenMetrics\\SharesCountMetric' => __DIR__ . '/..' . '/../lib/OpenMetrics/SharesCountMetric.php', 'OCA\\Files_Sharing\\OrphanHelper' => __DIR__ . '/..' . '/../lib/OrphanHelper.php', 'OCA\\Files_Sharing\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', 'OCA\\Files_Sharing\\Scanner' => __DIR__ . '/..' . '/../lib/Scanner.php', diff --git a/apps/files_sharing/lib/OpenMetrics/SharesCountMetric.php b/apps/files_sharing/lib/OpenMetrics/SharesCountMetric.php new file mode 100644 index 00000000000..ebf7972c3af --- /dev/null +++ b/apps/files_sharing/lib/OpenMetrics/SharesCountMetric.php @@ -0,0 +1,75 @@ + 'user', + IShare::TYPE_GROUP => 'group', + IShare::TYPE_LINK => 'link', + IShare::TYPE_EMAIL => 'email', + ]; + $qb = $this->connection->getQueryBuilder(); + $result = $qb->select($qb->func()->count('*', 'count'), 'share_type') + ->from('share') + ->where($qb->expr()->in('share_type', $qb->createNamedParameter(array_keys($types), IQueryBuilder::PARAM_INT_ARRAY))) + ->groupBy('share_type') + ->executeQuery(); + + if ($result->rowCount() === 0) { + yield new Metric(0); + return; + } + + foreach ($result->iterateAssociative() as $row) { + yield new Metric($row['count'], ['type' => $types[$row['share_type']]]); + } + } +} diff --git a/config/config.sample.php b/config/config.sample.php index d36eac3ed88..0da5c1e6e32 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -2892,4 +2892,29 @@ $CONFIG = [ * Defaults to `\OC::$SERVERROOT . '/resources/config/ca-bundle.crt'`. */ 'default_certificates_bundle_path' => \OC::$SERVERROOT . '/resources/config/ca-bundle.crt', + + /** + * OpenMetrics skipped exporters + * Allows to skip some exporters in the OpenMetrics endpoint ``/metrics``. + * + * Default to ``[]`` (empty array) + */ + 'openmetrics_skipped_classes' => [ + 'OC\OpenMetrics\Exporters\FilesByType', + 'OCA\Files_Sharing\OpenMetrics\SharesCount', + ], + + /** + * OpenMetrics allowed client IP addresses + * Restricts the IP addresses able to make requests on the ``/metrics`` endpoint. + * + * Keep this list as restrictive as possible as metrics can consume a lot of resources. + * + * Default to ``[127.0.0.0/16', '::1/128]`` (allow loopback interface only) + */ + 'openmetrics_allowed_clients' => [ + '192.168.0.0/16', + 'fe80::/10', + '10.0.0.1', + ], ]; diff --git a/core/Controller/OpenMetricsController.php b/core/Controller/OpenMetricsController.php new file mode 100644 index 00000000000..58f5288531f --- /dev/null +++ b/core/Controller/OpenMetricsController.php @@ -0,0 +1,155 @@ +isRemoteAddressAllowed()) { + return new Http\Response(Http::STATUS_FORBIDDEN); + } + + return new Http\StreamTraversableResponse( + $this->generate(), + Http::STATUS_OK, + [ + 'Content-Type' => 'application/openmetrics-text; version=1.0.0; charset=utf-8', + ] + ); + } + + private function isRemoteAddressAllowed(): bool { + $clientAddress = new Address($this->request->getRemoteAddress()); + $allowedRanges = $this->config->getSystemValue('openmetrics_allowed_clients', ['127.0.0.0/16', '::1/128']); + if (!is_array($allowedRanges)) { + $this->logger->warning('Invalid configuration for "openmetrics_allowed_clients"'); + return false; + } + + foreach ($allowedRanges as $range) { + $range = new Range($range); + if ($range->contains($clientAddress)) { + return true; + } + } + + return false; + } + + private function generate(): \Generator { + foreach ($this->exporterManager->export() as $family) { + yield $this->formatFamily($family); + } + + $elapsed = (string)(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']); + yield <<name(); + if ($family->type() !== MetricType::unknown) { + $output = '# TYPE nextcloud_' . $name . ' ' . $family->type()->name . "\n"; + } + if ($family->unit() !== '') { + $output .= '# UNIT nextcloud_' . $name . ' ' . $family->unit() . "\n"; + } + if ($family->help() !== '') { + $output .= '# HELP nextcloud_' . $name . ' ' . $family->help() . "\n"; + } + foreach ($family->metrics() as $metric) { + $output .= 'nextcloud_' . $name . $this->formatLabels($metric) . ' ' . $this->formatValue($metric); + if ($metric->timestamp !== null) { + $output .= ' ' . $this->formatTimestamp($metric); + } + $output .= "\n"; + } + $output .= "\n"; + + return $output; + } + + private function formatLabels(Metric $metric): string { + if (empty($metric->labels)) { + return ''; + } + + $labels = []; + foreach ($metric->labels as $label => $value) { + $labels[] .= $label . '=' . $this->escapeString((string)$value); + } + + return '{' . implode(',', $labels) . '}'; + } + + private function escapeString(string $string): string { + return json_encode( + $string, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + 1 + ); + } + + private function formatValue(Metric $metric): string { + if (is_bool($metric->value)) { + return $metric->value ? '1' : '0'; + } + if ($metric->value instanceof MetricValue) { + return $metric->value->value; + } + + return (string)$metric->value; + } + + private function formatTimestamp(Metric $metric): string { + return (string)$metric->timestamp; + } +} diff --git a/core/openapi-administration.json b/core/openapi-administration.json index f482f4992c1..569b6f9e421 100644 --- a/core/openapi-administration.json +++ b/core/openapi-administration.json @@ -659,6 +659,10 @@ { "name": "ocm", "description": "Controller about the endpoint /ocm-provider/" + }, + { + "name": "open_metrics", + "description": "OpenMetrics controller Gather and display metrics" } ] } diff --git a/core/openapi-ex_app.json b/core/openapi-ex_app.json index 647c9fcd5d9..927a7d788f6 100644 --- a/core/openapi-ex_app.json +++ b/core/openapi-ex_app.json @@ -1609,6 +1609,10 @@ { "name": "ocm", "description": "Controller about the endpoint /ocm-provider/" + }, + { + "name": "open_metrics", + "description": "OpenMetrics controller Gather and display metrics" } ] } diff --git a/core/openapi-full.json b/core/openapi-full.json index 2116be274a8..e6e456e2442 100644 --- a/core/openapi-full.json +++ b/core/openapi-full.json @@ -12238,6 +12238,10 @@ { "name": "ocm", "description": "Controller about the endpoint /ocm-provider/" + }, + { + "name": "open_metrics", + "description": "OpenMetrics controller Gather and display metrics" } ] } diff --git a/core/openapi.json b/core/openapi.json index 8b2b725d576..11d0318c837 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -10379,6 +10379,10 @@ { "name": "ocm", "description": "Controller about the endpoint /ocm-provider/" + }, + { + "name": "open_metrics", + "description": "OpenMetrics controller Gather and display metrics" } ] } diff --git a/lib/composer/composer/LICENSE b/lib/composer/composer/LICENSE index 62ecfd8d004..f27399a042d 100644 --- a/lib/composer/composer/LICENSE +++ b/lib/composer/composer/LICENSE @@ -1,3 +1,4 @@ + Copyright (c) Nils Adermann, Jordi Boggiano Permission is hereby granted, free of charge, to any person obtaining a copy @@ -17,3 +18,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 7cca0c7a414..5e9dc42f51d 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -122,6 +122,7 @@ return array( 'OCP\\AppFramework\\Http\\Response' => $baseDir . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamResponse.php', + 'OCP\\AppFramework\\Http\\StreamTraversableResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamTraversableResponse.php', 'OCP\\AppFramework\\Http\\StrictContentSecurityPolicy' => $baseDir . '/lib/public/AppFramework/Http/StrictContentSecurityPolicy.php', 'OCP\\AppFramework\\Http\\StrictEvalContentSecurityPolicy' => $baseDir . '/lib/public/AppFramework/Http/StrictEvalContentSecurityPolicy.php', 'OCP\\AppFramework\\Http\\StrictInlineContentSecurityPolicy' => $baseDir . '/lib/public/AppFramework/Http/StrictInlineContentSecurityPolicy.php', @@ -724,6 +725,10 @@ return array( 'OCP\\OCM\\IOCMProvider' => $baseDir . '/lib/public/OCM/IOCMProvider.php', 'OCP\\OCM\\IOCMResource' => $baseDir . '/lib/public/OCM/IOCMResource.php', 'OCP\\OCS\\IDiscoveryService' => $baseDir . '/lib/public/OCS/IDiscoveryService.php', + 'OCP\\OpenMetrics\\IMetricFamily' => $baseDir . '/lib/public/OpenMetrics/IMetricFamily.php', + 'OCP\\OpenMetrics\\Metric' => $baseDir . '/lib/public/OpenMetrics/Metric.php', + 'OCP\\OpenMetrics\\MetricType' => $baseDir . '/lib/public/OpenMetrics/MetricType.php', + 'OCP\\OpenMetrics\\MetricValue' => $baseDir . '/lib/public/OpenMetrics/MetricValue.php', 'OCP\\PreConditionNotMetException' => $baseDir . '/lib/public/PreConditionNotMetException.php', 'OCP\\Preview\\BeforePreviewFetchedEvent' => $baseDir . '/lib/public/Preview/BeforePreviewFetchedEvent.php', 'OCP\\Preview\\IMimeIconProvider' => $baseDir . '/lib/public/Preview/IMimeIconProvider.php', @@ -1422,6 +1427,7 @@ return array( 'OC\\Core\\Controller\\OCJSController' => $baseDir . '/core/Controller/OCJSController.php', 'OC\\Core\\Controller\\OCMController' => $baseDir . '/core/Controller/OCMController.php', 'OC\\Core\\Controller\\OCSController' => $baseDir . '/core/Controller/OCSController.php', + 'OC\\Core\\Controller\\OpenMetricsController' => $baseDir . '/core/Controller/OpenMetricsController.php', 'OC\\Core\\Controller\\PreviewController' => $baseDir . '/core/Controller/PreviewController.php', 'OC\\Core\\Controller\\ProfileApiController' => $baseDir . '/core/Controller/ProfileApiController.php', 'OC\\Core\\Controller\\RecommendedAppsController' => $baseDir . '/core/Controller/RecommendedAppsController.php', @@ -1895,6 +1901,17 @@ return array( 'OC\\OCS\\CoreCapabilities' => $baseDir . '/lib/private/OCS/CoreCapabilities.php', 'OC\\OCS\\DiscoveryService' => $baseDir . '/lib/private/OCS/DiscoveryService.php', 'OC\\OCS\\Provider' => $baseDir . '/lib/private/OCS/Provider.php', + 'OC\\OpenMetrics\\ExporterManager' => $baseDir . '/lib/private/OpenMetrics/ExporterManager.php', + 'OC\\OpenMetrics\\Exporters\\ActiveSessions' => $baseDir . '/lib/private/OpenMetrics/Exporters/ActiveSessions.php', + 'OC\\OpenMetrics\\Exporters\\ActiveUsers' => $baseDir . '/lib/private/OpenMetrics/Exporters/ActiveUsers.php', + 'OC\\OpenMetrics\\Exporters\\AppsCount' => $baseDir . '/lib/private/OpenMetrics/Exporters/AppsCount.php', + 'OC\\OpenMetrics\\Exporters\\AppsInfo' => $baseDir . '/lib/private/OpenMetrics/Exporters/AppsInfo.php', + 'OC\\OpenMetrics\\Exporters\\Cached' => $baseDir . '/lib/private/OpenMetrics/Exporters/Cached.php', + 'OC\\OpenMetrics\\Exporters\\FilesByType' => $baseDir . '/lib/private/OpenMetrics/Exporters/FilesByType.php', + 'OC\\OpenMetrics\\Exporters\\InstanceInfo' => $baseDir . '/lib/private/OpenMetrics/Exporters/InstanceInfo.php', + 'OC\\OpenMetrics\\Exporters\\Maintenance' => $baseDir . '/lib/private/OpenMetrics/Exporters/Maintenance.php', + 'OC\\OpenMetrics\\Exporters\\RunningJobs' => $baseDir . '/lib/private/OpenMetrics/Exporters/RunningJobs.php', + 'OC\\OpenMetrics\\Exporters\\UsersByBackend' => $baseDir . '/lib/private/OpenMetrics/Exporters/UsersByBackend.php', 'OC\\PhoneNumberUtil' => $baseDir . '/lib/private/PhoneNumberUtil.php', 'OC\\PreviewManager' => $baseDir . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => $baseDir . '/lib/private/PreviewNotAvailableException.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index c9fcc66a253..3616292f589 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -163,6 +163,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\Response' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamResponse.php', + 'OCP\\AppFramework\\Http\\StreamTraversableResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamTraversableResponse.php', 'OCP\\AppFramework\\Http\\StrictContentSecurityPolicy' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StrictContentSecurityPolicy.php', 'OCP\\AppFramework\\Http\\StrictEvalContentSecurityPolicy' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StrictEvalContentSecurityPolicy.php', 'OCP\\AppFramework\\Http\\StrictInlineContentSecurityPolicy' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StrictInlineContentSecurityPolicy.php', @@ -765,6 +766,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\OCM\\IOCMProvider' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMProvider.php', 'OCP\\OCM\\IOCMResource' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMResource.php', 'OCP\\OCS\\IDiscoveryService' => __DIR__ . '/../../..' . '/lib/public/OCS/IDiscoveryService.php', + 'OCP\\OpenMetrics\\IMetricFamily' => __DIR__ . '/../../..' . '/lib/public/OpenMetrics/IMetricFamily.php', + 'OCP\\OpenMetrics\\Metric' => __DIR__ . '/../../..' . '/lib/public/OpenMetrics/Metric.php', + 'OCP\\OpenMetrics\\MetricType' => __DIR__ . '/../../..' . '/lib/public/OpenMetrics/MetricType.php', + 'OCP\\OpenMetrics\\MetricValue' => __DIR__ . '/../../..' . '/lib/public/OpenMetrics/MetricValue.php', 'OCP\\PreConditionNotMetException' => __DIR__ . '/../../..' . '/lib/public/PreConditionNotMetException.php', 'OCP\\Preview\\BeforePreviewFetchedEvent' => __DIR__ . '/../../..' . '/lib/public/Preview/BeforePreviewFetchedEvent.php', 'OCP\\Preview\\IMimeIconProvider' => __DIR__ . '/../../..' . '/lib/public/Preview/IMimeIconProvider.php', @@ -1463,6 +1468,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Controller\\OCJSController' => __DIR__ . '/../../..' . '/core/Controller/OCJSController.php', 'OC\\Core\\Controller\\OCMController' => __DIR__ . '/../../..' . '/core/Controller/OCMController.php', 'OC\\Core\\Controller\\OCSController' => __DIR__ . '/../../..' . '/core/Controller/OCSController.php', + 'OC\\Core\\Controller\\OpenMetricsController' => __DIR__ . '/../../..' . '/core/Controller/OpenMetricsController.php', 'OC\\Core\\Controller\\PreviewController' => __DIR__ . '/../../..' . '/core/Controller/PreviewController.php', 'OC\\Core\\Controller\\ProfileApiController' => __DIR__ . '/../../..' . '/core/Controller/ProfileApiController.php', 'OC\\Core\\Controller\\RecommendedAppsController' => __DIR__ . '/../../..' . '/core/Controller/RecommendedAppsController.php', @@ -1936,6 +1942,17 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\OCS\\CoreCapabilities' => __DIR__ . '/../../..' . '/lib/private/OCS/CoreCapabilities.php', 'OC\\OCS\\DiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCS/DiscoveryService.php', 'OC\\OCS\\Provider' => __DIR__ . '/../../..' . '/lib/private/OCS/Provider.php', + 'OC\\OpenMetrics\\ExporterManager' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/ExporterManager.php', + 'OC\\OpenMetrics\\Exporters\\ActiveSessions' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/ActiveSessions.php', + 'OC\\OpenMetrics\\Exporters\\ActiveUsers' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/ActiveUsers.php', + 'OC\\OpenMetrics\\Exporters\\AppsCount' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/AppsCount.php', + 'OC\\OpenMetrics\\Exporters\\AppsInfo' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/AppsInfo.php', + 'OC\\OpenMetrics\\Exporters\\Cached' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/Cached.php', + 'OC\\OpenMetrics\\Exporters\\FilesByType' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/FilesByType.php', + 'OC\\OpenMetrics\\Exporters\\InstanceInfo' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/InstanceInfo.php', + 'OC\\OpenMetrics\\Exporters\\Maintenance' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/Maintenance.php', + 'OC\\OpenMetrics\\Exporters\\RunningJobs' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/RunningJobs.php', + 'OC\\OpenMetrics\\Exporters\\UsersByBackend' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporters/UsersByBackend.php', 'OC\\PhoneNumberUtil' => __DIR__ . '/../../..' . '/lib/private/PhoneNumberUtil.php', 'OC\\PreviewManager' => __DIR__ . '/../../..' . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => __DIR__ . '/../../..' . '/lib/private/PreviewNotAvailableException.php', diff --git a/lib/private/OpenMetrics/ExporterManager.php b/lib/private/OpenMetrics/ExporterManager.php new file mode 100644 index 00000000000..c66846be492 --- /dev/null +++ b/lib/private/OpenMetrics/ExporterManager.php @@ -0,0 +1,94 @@ +skippedClasses = array_fill_keys($config->getSystemValue('openmetrics_skipped_classes', []), true); + } + + public function export(): Generator { + // Core exporters + $exporters = [ + // Basic exporters + Exporters\InstanceInfo::class, + Exporters\AppsInfo::class, + Exporters\AppsCount::class, + Exporters\Maintenance::class, + + // File exporters + Exporters\FilesByType::class, + + // Users exporters + Exporters\ActiveUsers::class, + Exporters\ActiveSessions::class, + Exporters\UsersByBackend::class, + + // Jobs + Exporters\RunningJobs::class, + ]; + $exporters = array_filter($exporters, fn ($classname) => !isset($this->skippedClasses[$classname])); + foreach ($exporters as $classname) { + $exporter = $this->loadExporter($classname); + if ($exporter !== null) { + yield $exporter; + } + } + + // Apps exporters + foreach ($this->appManager->getEnabledApps() as $appId) { + $appInfo = $this->appManager->getAppInfo($appId); + if (!isset($appInfo[self::XML_ENTRY]) || !is_array($appInfo[self::XML_ENTRY])) { + continue; + } + foreach ($appInfo[self::XML_ENTRY] as $classname) { + if (isset($this->skippedClasses[$classname])) { + continue; + } + $exporter = $this->loadExporter($classname, $appId); + if ($exporter !== null) { + yield $exporter; + } + } + } + } + + private function loadExporter(string $classname, string $appId = 'core'): ?IMetricFamily { + try { + return Server::get($classname); + } catch (\Exception $e) { + $this->logger->error( + 'Unable to build exporter {exporter}', + [ + 'app' => $appId, + 'exception' => $e, + 'exporter' => $classname, + ], + ); + } + + return null; + } +} diff --git a/lib/private/OpenMetrics/Exporters/ActiveSessions.php b/lib/private/OpenMetrics/Exporters/ActiveSessions.php new file mode 100644 index 00000000000..b95279fa83e --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/ActiveSessions.php @@ -0,0 +1,58 @@ + $now - 5 * 60, + 'Last 15 minutes' => $now - 15 * 60, + 'Last hour' => $now - 60 * 60, + 'Last day' => $now - 24 * 60 * 60, + ]; + foreach ($timeFrames as $label => $time) { + $queryBuilder = $this->connection->getQueryBuilder(); + $result = $queryBuilder->select($queryBuilder->func()->count('*')) + ->from('authtoken') + ->where($queryBuilder->expr()->gte('last_activity', $queryBuilder->createNamedParameter($time))) + ->executeQuery(); + + yield new Metric((int)$result->fetchOne(), ['time' => $label]); + } + } +} diff --git a/lib/private/OpenMetrics/Exporters/ActiveUsers.php b/lib/private/OpenMetrics/Exporters/ActiveUsers.php new file mode 100644 index 00000000000..43425fd37e2 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/ActiveUsers.php @@ -0,0 +1,58 @@ + $now - 5 * 60, + 'Last 15 minutes' => $now - 15 * 60, + 'Last hour' => $now - 60 * 60, + 'Last day' => $now - 24 * 60 * 60, + ]; + foreach ($timeFrames as $label => $time) { + $qb = $this->connection->getQueryBuilder(); + $result = $qb->select($qb->createFunction('COUNT(DISTINCT ' . $qb->getColumnName('uid') . ')')) + ->from('authtoken') + ->where($qb->expr()->gte('last_activity', $qb->createNamedParameter($time))) + ->executeQuery(); + + yield new Metric((int)$result->fetchOne(), ['time' => $label]); + } + } +} diff --git a/lib/private/OpenMetrics/Exporters/AppsCount.php b/lib/private/OpenMetrics/Exporters/AppsCount.php new file mode 100644 index 00000000000..c168d33f005 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/AppsCount.php @@ -0,0 +1,62 @@ +appManager->getAppInstalledVersions(false)); + $enabledAppsCount = count($this->appManager->getEnabledApps()); + $disabledAppsCount = $installedAppsCount - $enabledAppsCount; + yield new Metric( + $disabledAppsCount, + ['status' => 'disabled'], + ); + yield new Metric( + $enabledAppsCount, + ['status' => 'enabled'], + ); + } +} diff --git a/lib/private/OpenMetrics/Exporters/AppsInfo.php b/lib/private/OpenMetrics/Exporters/AppsInfo.php new file mode 100644 index 00000000000..33597795e7d --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/AppsInfo.php @@ -0,0 +1,56 @@ +appManager->getAppInstalledVersions(true), + time() + ); + } +} diff --git a/lib/private/OpenMetrics/Exporters/Cached.php b/lib/private/OpenMetrics/Exporters/Cached.php new file mode 100644 index 00000000000..9b0a8b38687 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/Cached.php @@ -0,0 +1,58 @@ +cache = $cacheFactory->createDistributed('openmetrics'); + } + + /** + * Number of seconds to keep the results + */ + abstract public function getTTL(): int; + + /** + * Actually gather the metrics + * + * @see metrics + */ + abstract public function gatherMetrics(): Generator; + + #[Override] + public function metrics(): Generator { + $cacheKey = static::class; + if ($data = $this->cache->get($cacheKey)) { + yield from unserialize($data); + return; + } + + $data = []; + foreach ($this->gatherMetrics() as $metric) { + yield $metric; + $data[] = $metric; + } + + $this->cache->set($cacheKey, serialize($data), $this->getTTL()); + } +} diff --git a/lib/private/OpenMetrics/Exporters/FilesByType.php b/lib/private/OpenMetrics/Exporters/FilesByType.php new file mode 100644 index 00000000000..a201ad70aff --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/FilesByType.php @@ -0,0 +1,80 @@ +connection->getQueryBuilder()->runAcrossAllShards(); + $metrics = $qb->select('mimetype', $qb->func()->count('*', 'count')) + ->from('filecache') + ->groupBy('mimetype') + ->executeQuery(); + + if ($metrics->rowCount() === 0) { + yield new Metric(0); + return; + } + $now = time(); + foreach ($metrics->iterateAssociative() as $count) { + yield new Metric( + $count['count'], + ['mimetype' => $this->mimetypeLoader->getMimetypeById($count['mimetype'])], + $now, + ); + } + } +} diff --git a/lib/private/OpenMetrics/Exporters/InstanceInfo.php b/lib/private/OpenMetrics/Exporters/InstanceInfo.php new file mode 100644 index 00000000000..7b263efc169 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/InstanceInfo.php @@ -0,0 +1,63 @@ + $this->serverVersion->getHumanVersion(), + 'major version' => (string)$this->serverVersion->getVersion()[0], + 'build' => $this->serverVersion->getBuild(), + 'installed' => $this->systemConfig->getValue('installed', false) ? '1' : '0', + ], + time() + ); + } +} diff --git a/lib/private/OpenMetrics/Exporters/Maintenance.php b/lib/private/OpenMetrics/Exporters/Maintenance.php new file mode 100644 index 00000000000..89b53547e33 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/Maintenance.php @@ -0,0 +1,50 @@ +getValue('maintenance', false) + ); + } +} diff --git a/lib/private/OpenMetrics/Exporters/RunningJobs.php b/lib/private/OpenMetrics/Exporters/RunningJobs.php new file mode 100644 index 00000000000..98c901c8342 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/RunningJobs.php @@ -0,0 +1,67 @@ +connection->getQueryBuilder(); + $result = $qb->select($qb->func()->count('*', 'nb'), 'class') + ->from('jobs') + ->where($qb->expr()->gt('reserved_at', $qb->createNamedParameter(0))) + ->groupBy('class') + ->executeQuery(); + + // If no result, return a metric with count '0' + if ($result->rowCount() === 0) { + yield new Metric(0); + return; + } + + foreach ($result->iterateAssociative() as $row) { + yield new Metric($row['nb'], ['class' => $row['class']]); + } + } +} diff --git a/lib/private/OpenMetrics/Exporters/UsersByBackend.php b/lib/private/OpenMetrics/Exporters/UsersByBackend.php new file mode 100644 index 00000000000..a3156357745 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/UsersByBackend.php @@ -0,0 +1,55 @@ +userManager->countUsers(true); + foreach ($userCounts as $backend => $count) { + yield new Metric($count, ['backend' => $backend]); + } + } +} diff --git a/lib/private/User/Manager.php b/lib/private/User/Manager.php index 4a42a397a8e..2bea9bb65dd 100644 --- a/lib/private/User/Manager.php +++ b/lib/private/User/Manager.php @@ -443,22 +443,23 @@ class Manager extends PublicEmitter implements IUserManager { /** * returns how many users per backend exist (if supported by backend) * - * @param boolean $hasLoggedIn when true only users that have a lastLogin - * entry in the preferences table will be affected * @return array an array of backend class as key and count number as value */ - public function countUsers() { + public function countUsers(bool $onlyMappedUsers = false) { $userCountStatistics = []; foreach ($this->backends as $backend) { + $name = $backend instanceof IUserBackend + ? $backend->getBackendName() + : get_class($backend); + + if ($onlyMappedUsers && $backend instanceof ICountMappedUsersBackend) { + $userCountStatistics[$name] = $backend->countMappedUsers(); + continue; + } if ($backend instanceof ICountUsersBackend || $backend->implementsActions(Backend::COUNT_USERS)) { /** @var ICountUsersBackend|IUserBackend $backend */ $backendUsers = $backend->countUsers(); if ($backendUsers !== false) { - if ($backend instanceof IUserBackend) { - $name = $backend->getBackendName(); - } else { - $name = get_class($backend); - } if (isset($userCountStatistics[$name])) { $userCountStatistics[$name] += $backendUsers; } else { @@ -467,6 +468,7 @@ class Manager extends PublicEmitter implements IUserManager { } } } + return $userCountStatistics; } diff --git a/lib/public/AppFramework/Http/StreamTraversableResponse.php b/lib/public/AppFramework/Http/StreamTraversableResponse.php new file mode 100644 index 00000000000..aecf57c8059 --- /dev/null +++ b/lib/public/AppFramework/Http/StreamTraversableResponse.php @@ -0,0 +1,51 @@ + + * @template-extends Response> + */ +class StreamTraversableResponse extends Response implements ICallbackResponse { + /** + * @param S $status + * @param H $headers + * @since 33.0.0 + */ + public function __construct( + private Traversable $generator, + int $status = Http::STATUS_OK, + array $headers = [], + ) { + parent::__construct($status, $headers); + } + + + /** + * Streams the generator output + * + * @param IOutput $output a small wrapper that handles output + * @since 33.0.0 + */ + #[Override] + public function callback(IOutput $output): void { + foreach ($this->generator as $content) { + $output->setOutput($content); + flush(); + } + } +} diff --git a/lib/public/IUserManager.php b/lib/public/IUserManager.php index 226a52809a3..caf1a704cce 100644 --- a/lib/public/IUserManager.php +++ b/lib/public/IUserManager.php @@ -162,8 +162,9 @@ interface IUserManager { * * @return array an array of backend class name as key and count number as value * @since 8.0.0 + * @since 33.0.0 $onlyMappedUsers parameter */ - public function countUsers(); + public function countUsers(bool $onlyMappedUsers = false); /** * Get how many users exists in total, whithin limit diff --git a/lib/public/OpenMetrics/IMetricFamily.php b/lib/public/OpenMetrics/IMetricFamily.php new file mode 100644 index 00000000000..2fcc9c5d450 --- /dev/null +++ b/lib/public/OpenMetrics/IMetricFamily.php @@ -0,0 +1,53 @@ + + * @since 33.0.0 + */ + public function metrics(): Generator; +} diff --git a/lib/public/OpenMetrics/Metric.php b/lib/public/OpenMetrics/Metric.php new file mode 100644 index 00000000000..47e925b01b7 --- /dev/null +++ b/lib/public/OpenMetrics/Metric.php @@ -0,0 +1,27 @@ +labels[$name] ?? null; + } +} diff --git a/lib/public/OpenMetrics/MetricType.php b/lib/public/OpenMetrics/MetricType.php new file mode 100644 index 00000000000..07449593cc0 --- /dev/null +++ b/lib/public/OpenMetrics/MetricType.php @@ -0,0 +1,26 @@ +request = $this->createMock(IRequest::class); + $this->request->method('getRemoteAddress') + ->willReturn('192.168.1.1'); + $this->config = $this->createMock(IConfig::class); + $this->exporterManager = $this->createMock(ExporterManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->controller = new OpenMetricsController('core', $this->request, $this->config, $this->exporterManager, $this->logger); + } + + public function testGetMetrics(): void { + $output = $this->createMock(IOutput::class); + $fullOutput = ''; + $output->method('setOutput') + ->willReturnCallback(function ($output) use (&$fullOutput) { + $fullOutput .= $output; + }); + $this->config->expects($this->once()) + ->method('getSystemValue') + ->with('openmetrics_allowed_clients') + ->willReturn(['192.168.0.0/16']); + $response = $this->controller->export(); + $this->assertInstanceOf(StreamTraversableResponse::class, $response); + $this->assertEquals('200', $response->getStatus()); + $this->assertEquals('application/openmetrics-text; version=1.0.0; charset=utf-8', $response->getHeaders()['Content-Type']); + $expected = <<callback($output); + $this->assertStringMatchesFormat($expected, $fullOutput); + } + + public function testGetMetricsFromForbiddenIp(): void { + $this->config->expects($this->once()) + ->method('getSystemValue') + ->with('openmetrics_allowed_clients') + ->willReturn(['1.2.3.4']); + $response = $this->controller->export(); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals('403', $response->getStatus()); + } +} diff --git a/tests/lib/InfoXmlTest.php b/tests/lib/InfoXmlTest.php index 9506f87c1b0..4e2620439d2 100644 --- a/tests/lib/InfoXmlTest.php +++ b/tests/lib/InfoXmlTest.php @@ -9,6 +9,7 @@ namespace Test; use OCP\App\IAppManager; use OCP\AppFramework\App; +use OCP\OpenMetrics\IMetricFamily; use OCP\Server; /** @@ -130,5 +131,14 @@ class InfoXmlTest extends TestCase { $this->assertInstanceOf($command, Server::get($command)); } } + + if (isset($appInfo['openmetrics'])) { + foreach ($appInfo['openmetrics'] as $class) { + $this->assertTrue(class_exists($class), 'Asserting exporter "' . $class . '"exists'); + $exporter = Server::get($class); + $this->assertInstanceOf($class, $exporter); + $this->assertInstanceOf(IMetricFamily::class, $exporter); + } + } } } diff --git a/tests/lib/OpenMetrics/ExporterManagerTest.php b/tests/lib/OpenMetrics/ExporterManagerTest.php new file mode 100644 index 00000000000..31f407ee705 --- /dev/null +++ b/tests/lib/OpenMetrics/ExporterManagerTest.php @@ -0,0 +1,23 @@ +assertInstanceOf(ExporterManager::class, $exporter); + foreach ($exporter->export() as $metric) { + $this->assertInstanceOf(IMetricFamily::class, $metric); + }; + } +} diff --git a/tests/lib/OpenMetrics/Exporters/ActiveSessionsTest.php b/tests/lib/OpenMetrics/Exporters/ActiveSessionsTest.php new file mode 100644 index 00000000000..ebbe3b061d3 --- /dev/null +++ b/tests/lib/OpenMetrics/Exporters/ActiveSessionsTest.php @@ -0,0 +1,32 @@ +assertLabelsAre([ + ['time' => 'Last 5 minutes'], + ['time' => 'Last 15 minutes'], + ['time' => 'Last hour'], + ['time' => 'Last day'], + ]); + } +} diff --git a/tests/lib/OpenMetrics/Exporters/ActiveUsersTest.php b/tests/lib/OpenMetrics/Exporters/ActiveUsersTest.php new file mode 100644 index 00000000000..1a41ee2a43b --- /dev/null +++ b/tests/lib/OpenMetrics/Exporters/ActiveUsersTest.php @@ -0,0 +1,32 @@ +assertLabelsAre([ + ['time' => 'Last 5 minutes'], + ['time' => 'Last 15 minutes'], + ['time' => 'Last hour'], + ['time' => 'Last day'], + ]); + } +} diff --git a/tests/lib/OpenMetrics/Exporters/AppsCountTest.php b/tests/lib/OpenMetrics/Exporters/AppsCountTest.php new file mode 100644 index 00000000000..c1617e1ba1d --- /dev/null +++ b/tests/lib/OpenMetrics/Exporters/AppsCountTest.php @@ -0,0 +1,38 @@ +appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppInstalledVersions') + ->with(false) + ->willReturn(['app1', 'app2', 'app3', 'app4', 'app5']); + $this->appManager->method('getEnabledApps') + ->willReturn(['app1', 'app2', 'app3']); + return new AppsCount($this->appManager); + } + + public function testMetrics(): void { + foreach ($this->metrics as $metric) { + $expectedValue = match ($metric->label('status')) { + 'disabled' => 2, + 'enabled' => 3, + }; + $this->assertEquals($expectedValue, $metric->value); + } + } +} diff --git a/tests/lib/OpenMetrics/Exporters/AppsInfoTest.php b/tests/lib/OpenMetrics/Exporters/AppsInfoTest.php new file mode 100644 index 00000000000..5baa85af3f9 --- /dev/null +++ b/tests/lib/OpenMetrics/Exporters/AppsInfoTest.php @@ -0,0 +1,37 @@ + '0.1.2', + 'appB' => '1.2.3 beta 4', + ]; + + protected function getExporter():IMetricFamily { + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->method('getAppInstalledVersions') + ->with(true) + ->willReturn($this->appList); + + return new AppsInfo($this->appManager); + } + + public function testMetrics(): void { + $this->assertCount(1, $this->metrics); + $metric = array_pop($this->metrics); + $this->assertSame($this->appList, $metric->labels); + } +} diff --git a/tests/lib/OpenMetrics/Exporters/ExporterTestCase.php b/tests/lib/OpenMetrics/Exporters/ExporterTestCase.php new file mode 100644 index 00000000000..cf7ffbdab18 --- /dev/null +++ b/tests/lib/OpenMetrics/Exporters/ExporterTestCase.php @@ -0,0 +1,41 @@ +exporter = $this->getExporter(); + $this->metrics = iterator_to_array($this->exporter->metrics()); + } + + public function testNotEmptyData() { + $this->assertNotEmpty($this->exporter->name()); + $this->assertNotEmpty($this->metrics); + } + + protected function assertLabelsAre(array $expectedLabels) { + $foundLabels = []; + foreach ($this->metrics as $metric) { + $foundLabels[] = $metric->labels; + } + + $this->assertSame($foundLabels, $expectedLabels); + } +} diff --git a/tests/lib/OpenMetrics/Exporters/FilesByTypeTest.php b/tests/lib/OpenMetrics/Exporters/FilesByTypeTest.php new file mode 100644 index 00000000000..030f9caf9a0 --- /dev/null +++ b/tests/lib/OpenMetrics/Exporters/FilesByTypeTest.php @@ -0,0 +1,29 @@ +systemConfig = $this->createMock(SystemConfig::class); + $this->serverVersion = $this->createMock(ServerVersion::class); + $this->serverVersion->method('getHumanVersion')->willReturn('33.13.17 Gold'); + $this->serverVersion->method('getVersion')->willReturn([33, 13, 17]); + $this->serverVersion->method('getBuild')->willReturn('dev'); + + return new InstanceInfo($this->systemConfig, $this->serverVersion); + } + + public function testMetrics(): void { + $this->assertCount(1, $this->metrics); + $metric = array_pop($this->metrics); + $this->assertSame([ + 'full version' => '33.13.17 Gold', + 'major version' => '33', + 'build' => 'dev', + 'installed' => '0', + ], $metric->labels); + } +} diff --git a/tests/lib/OpenMetrics/Exporters/MaintenanceTest.php b/tests/lib/OpenMetrics/Exporters/MaintenanceTest.php new file mode 100644 index 00000000000..5509c318eca --- /dev/null +++ b/tests/lib/OpenMetrics/Exporters/MaintenanceTest.php @@ -0,0 +1,19 @@ + 42, + 'backend B' => 51, + 'backend C' => 0, + ]; + + + protected function getExporter():IMetricFamily { + $this->userManager = $this->createMock(IUserManager::class); + $this->userManager->method('countUsers') + ->with(true) + ->willReturn($this->backendList); + return new UsersByBackend($this->userManager); + } + + public function testMetrics(): void { + foreach ($this->metrics as $metric) { + $this->assertEquals($this->backendList[$metric->label('backend')], $metric->value); + } + } +}