diff --git a/apps/comments/appinfo/info.xml b/apps/comments/appinfo/info.xml index 956a8fe39142e..7510bbdf53dfb 100644 --- a/apps/comments/appinfo/info.xml +++ b/apps/comments/appinfo/info.xml @@ -38,6 +38,10 @@ + + OCA\Comments\OpenMetrics\Comments + + OCA\Comments\Collaboration\CommentersSorter diff --git a/apps/comments/composer/composer/autoload_classmap.php b/apps/comments/composer/composer/autoload_classmap.php index 6db5c6a232b6a..22ed3a35542de 100644 --- a/apps/comments/composer/composer/autoload_classmap.php +++ b/apps/comments/composer/composer/autoload_classmap.php @@ -22,5 +22,6 @@ '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\\Comments' => $baseDir . '/../lib/OpenMetrics/Comments.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 60359abb6d0e6..520b640625077 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\\Comments' => __DIR__ . '/..' . '/../lib/OpenMetrics/Comments.php', 'OCA\\Comments\\Search\\CommentsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/CommentsSearchProvider.php', ); diff --git a/apps/comments/lib/OpenMetrics/Comments.php b/apps/comments/lib/OpenMetrics/Comments.php new file mode 100644 index 0000000000000..38cfd6bb104e3 --- /dev/null +++ b/apps/comments/lib/OpenMetrics/Comments.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 9368225fa2436..c6d60c7f5c114 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\SharesCount + diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index 48f197f9bf937..f29b6c341dc50 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -88,6 +88,7 @@ '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\\SharesCount' => $baseDir . '/../lib/OpenMetrics/SharesCount.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 110a64fb3acfa..e43a9298a33e3 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\\SharesCount' => __DIR__ . '/..' . '/../lib/OpenMetrics/SharesCount.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/SharesCount.php b/apps/files_sharing/lib/OpenMetrics/SharesCount.php new file mode 100644 index 0000000000000..86246db1ac0f8 --- /dev/null +++ b/apps/files_sharing/lib/OpenMetrics/SharesCount.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; + } + + while ($row = $result->fetch()) { + yield new Metric($row['count'], ['type' => $types[$row['share_type']]]); + } + } +} diff --git a/config/config.sample.php b/config/config.sample.php index d36eac3ed88c1..0da5c1e6e3241 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -2892,4 +2892,29 @@ * 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 0000000000000..0f70db5626992 --- /dev/null +++ b/core/Controller/OpenMetricsController.php @@ -0,0 +1,152 @@ +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 { + $exporter = $this->exporter; + + foreach ($exporter() as $family) { + $output = ''; + $name = $family->name(); + if ($family->type() !== MetricTypes::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"; + + yield $output; + } + + $elapsed = (string)(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']); + yield <<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 f482f4992c131..569b6f9e42184 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 647c9fcd5d916..927a7d788f6b3 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 2116be274a863..e6e456e244289 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 8b2b725d5769c..11d0318c837cc 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 62ecfd8d0046b..da27360bf626e 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 diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 0b6dd29f2040b..4c41470a07d9f 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -122,6 +122,7 @@ '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 @@ '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\\MetricTypes' => $baseDir . '/lib/public/OpenMetrics/MetricTypes.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', @@ -1420,6 +1425,7 @@ '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', @@ -1893,6 +1899,17 @@ '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\\Exporter' => $baseDir . '/lib/private/OpenMetrics/Exporter.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 9c34d2cd4debd..5abfbd4a325b4 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\\MetricTypes' => __DIR__ . '/../../..' . '/lib/public/OpenMetrics/MetricTypes.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', @@ -1461,6 +1466,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', @@ -1934,6 +1940,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\\Exporter' => __DIR__ . '/../../..' . '/lib/private/OpenMetrics/Exporter.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/Exporter.php b/lib/private/OpenMetrics/Exporter.php new file mode 100644 index 0000000000000..aba387d1b5223 --- /dev/null +++ b/lib/private/OpenMetrics/Exporter.php @@ -0,0 +1,97 @@ +skippedClasses = array_fill_keys($config->getSystemValue('openmetrics_skipped_classes', []), true); + } + + public function __invoke(): 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 $appName) { + $appInfo = $this->appManager->getAppInfo($appName); + 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, $appName); + if ($exporter !== null) { + yield $exporter; + } + } + } + } + + private function loadExporter(string $classname, string $appName = 'core'): ?IMetricFamily { + try { + return Server::get($classname); + } catch (\Exception $e) { + $this->logger->error( + 'Unable to build exporter {exporter}', + [ + 'app' => $appName, + '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 0000000000000..50013ae970b30 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/ActiveSessions.php @@ -0,0 +1,61 @@ + $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 0000000000000..1b5267254d741 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/ActiveUsers.php @@ -0,0 +1,61 @@ + $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 0000000000000..ee038635fb331 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/AppsCount.php @@ -0,0 +1,64 @@ +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 0000000000000..0adb53a92e360 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/AppsInfo.php @@ -0,0 +1,58 @@ +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 0000000000000..298eedebba855 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/Cached.php @@ -0,0 +1,60 @@ +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 = get_called_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 0000000000000..d2d9e3ceb2bef --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/FilesByType.php @@ -0,0 +1,77 @@ +connection->getQueryBuilder(); + $metrics = $qb->select('M.mimetype', $qb->func()->count('*', 'count')) + ->from('filecache', 'F') + ->join('F', 'mimetypes', 'M', $qb->expr()->eq('F.mimetype', 'M.id')) + ->groupBy('M.mimetype') + ->executeQuery(); + + if ($metrics->rowCount() === 0) { + yield new Metric(0); + return; + } + $now = time(); + while ($count = $metrics->fetch()) { + yield new Metric($count['count'], ['mimetype' => $count['mimetype']], $now); + } + } +} diff --git a/lib/private/OpenMetrics/Exporters/InstanceInfo.php b/lib/private/OpenMetrics/Exporters/InstanceInfo.php new file mode 100644 index 0000000000000..4c1e1507ba83f --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/InstanceInfo.php @@ -0,0 +1,64 @@ + $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 0000000000000..f74b8b391599d --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/Maintenance.php @@ -0,0 +1,52 @@ +getValue('maintenance', false) + ); + } +} diff --git a/lib/private/OpenMetrics/Exporters/RunningJobs.php b/lib/private/OpenMetrics/Exporters/RunningJobs.php new file mode 100644 index 0000000000000..ac8abefa1a425 --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/RunningJobs.php @@ -0,0 +1,69 @@ +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; + } + + while ($row = $result->fetch()) { + 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 0000000000000..937e23b242a8b --- /dev/null +++ b/lib/private/OpenMetrics/Exporters/UsersByBackend.php @@ -0,0 +1,57 @@ +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 4a42a397a8e47..2bea9bb65dd74 100644 --- a/lib/private/User/Manager.php +++ b/lib/private/User/Manager.php @@ -443,22 +443,23 @@ public function createUserFromBackend($uid, $password, UserInterface $backend) { /** * 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 @@ public function countUsers() { } } } + return $userCountStatistics; } diff --git a/lib/public/AppFramework/Http/StreamTraversableResponse.php b/lib/public/AppFramework/Http/StreamTraversableResponse.php new file mode 100644 index 0000000000000..aecf57c80596f --- /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 226a52809a3d0..caf1a704cce99 100644 --- a/lib/public/IUserManager.php +++ b/lib/public/IUserManager.php @@ -162,8 +162,9 @@ public function createUserFromBackend($uid, $password, UserInterface $backend); * * @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 0000000000000..21b54496ecbcb --- /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 0000000000000..47e925b01b75f --- /dev/null +++ b/lib/public/OpenMetrics/Metric.php @@ -0,0 +1,27 @@ +labels[$name] ?? null; + } +} diff --git a/lib/public/OpenMetrics/MetricTypes.php b/lib/public/OpenMetrics/MetricTypes.php new file mode 100644 index 0000000000000..54343aa65c9cc --- /dev/null +++ b/lib/public/OpenMetrics/MetricTypes.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->exporter = $this->createMock(Exporter::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->controller = new OpenMetricsController('core', $this->request, $this->config, $this->exporter, $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 9506f87c1b0a0..4e2620439d23c 100644 --- a/tests/lib/InfoXmlTest.php +++ b/tests/lib/InfoXmlTest.php @@ -9,6 +9,7 @@ use OCP\App\IAppManager; use OCP\AppFramework\App; +use OCP\OpenMetrics\IMetricFamily; use OCP\Server; /** @@ -130,5 +131,14 @@ public function testClasses($app): void { $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/ExporterTest.php b/tests/lib/OpenMetrics/ExporterTest.php new file mode 100644 index 0000000000000..30d717dce8afb --- /dev/null +++ b/tests/lib/OpenMetrics/ExporterTest.php @@ -0,0 +1,23 @@ +assertInstanceOf(Exporter::class, $exporter); + foreach ($exporter() 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 0000000000000..ebbe3b061d3cc --- /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 0000000000000..1a41ee2a43b0b --- /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 0000000000000..c1617e1ba1db4 --- /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 0000000000000..5baa85af3f9f3 --- /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 0000000000000..cf7ffbdab1815 --- /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 0000000000000..a87e42864808f --- /dev/null +++ b/tests/lib/OpenMetrics/Exporters/FilesByTypeTest.php @@ -0,0 +1,27 @@ +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 0000000000000..5509c318ecae0 --- /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); + } + } +}