Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.04% covered (success)
98.04%
50 / 51
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
EnvironmentCollector
98.04% covered (success)
98.04%
50 / 51
85.71% covered (warning)
85.71%
6 / 7
18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 metricIds
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 collect
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 countRecentlyActiveUsers
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 backgroundJobMode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 databaseVersion
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 architecture
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\UsageStatistics\Collector;
10
11use OCA\Libresign\Service\Install\InstallTarget;
12use OCA\Libresign\Service\UsageStatistics\Contract\IMetricCollector;
13use OCA\Libresign\Service\UsageStatistics\Model\MetricValue;
14use OCA\Libresign\Service\UsageStatistics\Model\ReportMode;
15use OCA\Libresign\Service\UsageStatistics\Model\ReportPeriod;
16use OCA\Libresign\Service\UsageStatistics\Source\RuntimeEnvironment;
17use OCA\Libresign\Service\UsageStatistics\UsageStatisticsException;
18use OCP\AppFramework\Utility\ITimeFactory;
19use OCP\IAppConfig;
20use OCP\IUserManager;
21
22/**
23 * Current state of the Nextcloud server and its runtime. There is no record
24 * of these values in the past, so historical reports omit all of them.
25 */
26class EnvironmentCollector implements IMetricCollector {
27    private const ACTIVE_USERS_DAYS = 30;
28    // Nextcloud returns at most 100 users per call, whatever the limit asked.
29    private const LAST_LOGIN_BATCH_SIZE = 100;
30    private const BACKGROUND_JOB_MODES = ['ajax', 'webcron', 'cron'];
31
32    public function __construct(
33        private IUserManager $userManager,
34        private IAppConfig $appConfig,
35        private ITimeFactory $timeFactory,
36        private RuntimeEnvironment $runtime,
37    ) {
38    }
39
40    #[\Override]
41    public function metricIds(): array {
42        return [
43            'nextcloud.version',
44            'nextcloud.users_total',
45            'nextcloud.users_active_30d',
46            'nextcloud.background_job_mode',
47            'php.version',
48            'database.type',
49            'database.version',
50            'system.os_family',
51            'system.architecture',
52        ];
53    }
54
55    #[\Override]
56    public function collect(ReportPeriod $period, ReportMode $mode): array {
57        if ($mode === ReportMode::HISTORICAL) {
58            return array_fill_keys($this->metricIds(), MetricValue::unavailable());
59        }
60
61        $usersTotal = $this->userManager->countUsersTotal();
62        $databaseVersion = $this->databaseVersion();
63        return [
64            'nextcloud.version' => MetricValue::of($this->runtime->nextcloudVersion()),
65            // Some user backends cannot count their users.
66            'nextcloud.users_total' => $usersTotal === false ? MetricValue::unavailable() : MetricValue::of($usersTotal),
67            'nextcloud.users_active_30d' => MetricValue::of($this->countRecentlyActiveUsers()),
68            'nextcloud.background_job_mode' => MetricValue::of($this->backgroundJobMode()),
69            'php.version' => MetricValue::of($this->runtime->phpVersion()),
70            'database.type' => MetricValue::of($this->runtime->databasePlatform()),
71            'database.version' => $databaseVersion === null ? MetricValue::unavailable() : MetricValue::of($databaseVersion),
72            'system.os_family' => MetricValue::of($this->runtime->osFamily()),
73            'system.architecture' => MetricValue::of($this->architecture()),
74        ];
75    }
76
77    /**
78     * Users come sorted by their last login, most recent first, so counting
79     * stops at the first one outside the window. Pages advance by what each
80     * call returned and end on an empty one, so a server that caps the batch
81     * below the requested size cannot end the count early.
82     */
83    private function countRecentlyActiveUsers(): int {
84        $since = $this->timeFactory->getTime() - self::ACTIVE_USERS_DAYS * 86400;
85        $count = 0;
86        $offset = 0;
87        while (($userIds = $this->userManager->getLastLoggedInUsers(self::LAST_LOGIN_BATCH_SIZE, $offset)) !== []) {
88            foreach ($userIds as $userId) {
89                $user = $this->userManager->get($userId);
90                if ($user === null) {
91                    continue;
92                }
93                if ($user->getLastLogin() < $since) {
94                    return $count;
95                }
96                $count++;
97            }
98            $offset += count($userIds);
99        }
100        return $count;
101    }
102
103    private function backgroundJobMode(): string {
104        $mode = $this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax');
105        return in_array($mode, self::BACKGROUND_JOB_MODES, true) ? $mode : 'other';
106    }
107
108    /**
109     * Only major.minor: full version strings carry build and distribution details.
110     */
111    private function databaseVersion(): ?string {
112        $version = $this->runtime->databaseServerVersion();
113        if ($version === null) {
114            return null;
115        }
116        if (preg_match('/^(\d+)\.(\d+)/', trim($version), $matches) !== 1) {
117            throw new UsageStatisticsException('Unrecognized database version format');
118        }
119        return $matches[1] . '.' . $matches[2];
120    }
121
122    private function architecture(): string {
123        try {
124            return InstallTarget::normalizeArchitecture($this->runtime->machine());
125        } catch (\InvalidArgumentException) {
126            return 'other';
127        }
128    }
129}