From c867156cae4b916b98b1e1462e7bac30502a952b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Mon, 5 Oct 2026 21:56:52 -0300 Subject: [PATCH 1/5] feat(usage-statistics): add the LibreSign usage statistics schema v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the 22 metrics of schema v1 in the format the usage statistics server registers, under appinfo/usage-statistics/schema-v1.json. The server accepts only the metric fields it knows, so the source of truth and the historical reconstruction rule of each metric are documented in the README next to it. Snapshot metrics are optional because historical reports cannot know past environment values. The monthly counters that can be rebuilt for every month are required. Part of #8932 Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: André Maia --- REUSE.toml | 1 + appinfo/usage-statistics/README.md | 66 +++++++++++++++++++++++++ appinfo/usage-statistics/schema-v1.json | 28 +++++++++++ 3 files changed, 95 insertions(+) create mode 100644 appinfo/usage-statistics/README.md create mode 100644 appinfo/usage-statistics/schema-v1.json diff --git a/REUSE.toml b/REUSE.toml index 7698fd6add..aa0491b531 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -20,6 +20,7 @@ path = [ ".github/copilot-instructions.md", ".patches/**", "appinfo/info.xml", + "appinfo/usage-statistics/schema-v1.json", "composer.json", "composer.lock", ".github/ISSUE_TEMPLATE/2_feature_request.md", diff --git a/appinfo/usage-statistics/README.md b/appinfo/usage-statistics/README.md new file mode 100644 index 0000000000..f4d3b926d7 --- /dev/null +++ b/appinfo/usage-statistics/README.md @@ -0,0 +1,66 @@ + +# LibreSign usage statistics schema + +`schema-v1.json` is the metric contract LibreSign registers with the usage statistics server. It uses only the fields the server accepts. This file records what the JSON cannot hold: where each value comes from and how a past month is rebuilt. + +A registered schema version is immutable. Any change to a metric, including a new optional one, needs a new `schemaVersion`. A new metric must not shorten the history of the metrics we can already rebuild. + +## Reports + +- Each report covers one calendar month in UTC. The start is inclusive and the end exclusive. +- The *current* report covers the most recent closed month and includes the snapshots of the environment at collection time. +- A *historical* report rebuilds an older month from stored events. It never includes snapshots, because LibreSign has no record of past versions or settings. +- `0` is a known zero: no activity in a month that can be rebuilt. A missing metric means its value is unknown for that month. +- Report generation fails instead of leaving a metric out when that metric should be available but is missing or invalid. +- Every metric has exactly one collector in `lib/Service/UsageStatistics/Collector/`, and a test enforces this. + +## Counting rules + +- A signing request is one participant expected to sign. Observers are counted only in `participants.observers_added`. +- An envelope and each of its files carry their own signing request for the same signer. Signing metrics count only requests on top-level files, that is, standalone files and envelopes, never the files inside an envelope. +- Identification documents also create LibreSign files and signing requests, but they are not signing requests. They are left out of every document and signing metric. +- Counts are aggregates of events and never deduplicate people. +- Deleting a signing request, deleting the signed file and `occ libresign:crl:cleanup` remove rows. A rebuilt month is therefore a lower bound of what happened. + +## Metrics + +### Snapshots: current report only + +| Metric | Source of truth | +|---|---| +| `nextcloud.version` | `OCP\ServerVersion`, major.minor.patch | +| `nextcloud.users_total` | `IUserManager::countUsersTotal()`. Unavailable when a user backend cannot count. | +| `nextcloud.users_active_30d` | Users whose last login is within 30 days of collection | +| `nextcloud.background_job_mode` | `core` / `backgroundjobs_mode`, reduced to `ajax`, `webcron`, `cron` or `other` | +| `php.version` | PHP major.minor | +| `database.type` | `IDBConnection::getDatabaseProvider(true)` | +| `database.version` | The server version reduced to major.minor. Unavailable on Oracle. | +| `system.os_family` | `PHP_OS_FAMILY` | +| `system.architecture` | Machine type normalized to `x86_64`, `aarch64` or `other`. Never the host name or kernel. | +| `libresign.version` | Installed LibreSign version | +| `libresign.signature_engine` | `signature_engine` setting. Anything other than `PhpNative` runs `JSignPdf`. | +| `libresign.signing_mode` | `signing_mode` setting, `sync` or `async` | +| `certificate.engine` | `certificate_engine` setting: `openssl`, `cfssl`, `none` or `other` | +| `certificate.root_ca_configured` | `ca.pem` and `ca-key.pem` exist in the stored PKI path. The value is read without creating the path or contacting CFSSL. | + +No certificate subject, issuer, serial number, CA identifier, path or URL is ever reported. + +### Monthly counters + +| Metric | Event timestamp | History | +|---|---|---| +| `documents.files_created` | `libresign_file.created_at` of top-level files that are not envelopes and have no files | Since the first install | +| `documents.envelopes_created` | `libresign_file.created_at` of top-level files stored as envelopes or holding files | Envelopes arrived in 12.2/13. Earlier months are a known zero. | +| `signatures.requests_created` | `libresign_sign_request.created_at`, signers only | Since the first install | +| `signatures.completed` | `libresign_sign_request.signed`, signers only | Since the first install | +| `signatures.rejected` | `libresign_sign_request.rejected_at`, signers only | Rejection arrived in 15.0 together with the column. Earlier months are a known zero. | +| `participants.observers_added` | `libresign_sign_request.created_at`, observers only | Observers arrived together with `participant_role`. Earlier months are a known zero. | +| `certificate.certificates_issued` | `libresign_crl.issued_at` of `leaf` certificates | See below | +| `certificate.certificates_revoked` | `libresign_crl.revoked_at` of `leaf` certificates | See below | + +Certificates are recorded only since 12.1 (OpenSSL) and 12.2/13 (CFSSL). The certificate counters are known for every month when the first recorded certificate is not later than the first LibreSign file. This is a fresh install, which creates its root CA first. Otherwise the instance was upgraded, and they are known only for months that start after the first recorded certificate. + +The historical series starts at the month of the first LibreSign file. diff --git a/appinfo/usage-statistics/schema-v1.json b/appinfo/usage-statistics/schema-v1.json new file mode 100644 index 0000000000..b431ac38e6 --- /dev/null +++ b/appinfo/usage-statistics/schema-v1.json @@ -0,0 +1,28 @@ +{ + "application": "libresign", + "schemaVersion": 1, + "metrics": [ + {"category": "nextcloud", "key": "version", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Nextcloud server version (major.minor.patch)", "required": false}, + {"category": "nextcloud", "key": "users_total", "type": "integer", "kind": "snapshot", "aggregation": "numerical", "description": "Number of user accounts", "required": false}, + {"category": "nextcloud", "key": "users_active_30d", "type": "integer", "kind": "snapshot", "aggregation": "numerical", "description": "Number of users who logged in during the last 30 days", "required": false}, + {"category": "nextcloud", "key": "background_job_mode", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Background job mode: ajax, webcron, cron or other", "required": false}, + {"category": "php", "key": "version", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "PHP version (major.minor)", "required": false}, + {"category": "database", "key": "type", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Database platform: mysql, mariadb, postgres, sqlite or oracle", "required": false}, + {"category": "database", "key": "version", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Database server version (major.minor)", "required": false}, + {"category": "system", "key": "os_family", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Operating system family reported by PHP", "required": false}, + {"category": "system", "key": "architecture", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "CPU architecture: x86_64, aarch64 or other", "required": false}, + {"category": "libresign", "key": "version", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "LibreSign version", "required": false}, + {"category": "libresign", "key": "signature_engine", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "PDF signature engine: JSignPdf or PhpNative", "required": false}, + {"category": "libresign", "key": "signing_mode", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Signing mode: sync or async", "required": false}, + {"category": "certificate", "key": "engine", "type": "string", "kind": "snapshot", "aggregation": "distribution", "description": "Certificate engine: openssl, cfssl, none or other", "required": false}, + {"category": "certificate", "key": "root_ca_configured", "type": "boolean", "kind": "snapshot", "aggregation": "distribution", "description": "Whether a root certificate authority is configured", "required": false}, + {"category": "certificate", "key": "certificates_issued", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing certificates issued during the period", "required": false}, + {"category": "certificate", "key": "certificates_revoked", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing certificates revoked during the period", "required": false}, + {"category": "documents", "key": "files_created", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Standalone files created during the period", "required": true}, + {"category": "documents", "key": "envelopes_created", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Envelopes created during the period", "required": true}, + {"category": "signatures", "key": "requests_created", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing requests created during the period", "required": true}, + {"category": "signatures", "key": "completed", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing requests completed during the period", "required": true}, + {"category": "signatures", "key": "rejected", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Signing requests rejected during the period", "required": true}, + {"category": "participants", "key": "observers_added", "type": "integer", "kind": "period", "aggregation": "numerical", "description": "Observers added during the period", "required": true} + ] +} From 02cb10fcb8ad95afddad0dadd5c2a5c871cdebf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Mon, 5 Oct 2026 21:57:15 -0300 Subject: [PATCH 2/5] feat(usage-statistics): build usage reports that fail safely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the report model, the schema reader and the report builder. The builder turns collector answers into a monthly report (UTC, start-inclusive and end-exclusive) for the current or a historical period, without side effects, so the same report can later be previewed, stored and sent. A collector answers each of its metrics with a value, including a known zero, or states that the value is unavailable for the period. The whole report fails when a schema metric has no collector or more than one, when an answer is missing or invalid, when a required metric is unavailable, or when a current snapshot is claimed for a past period. The schema reader applies the same checks as the usage statistics server, so a schema it would refuse fails here first. UsageActivityReader counts the stored events by their own timestamps. Signing activity is counted on top-level files only, because an envelope and each of its files carry a request for the same signer. Identification documents are left out, and observers are counted apart from signers. Part of #8932 Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: André Maia --- .../Contract/IMetricCollector.php | 30 +++ .../UsageStatistics/Model/CollectedMetric.php | 29 +++ .../Model/MetricDefinition.php | 33 +++ .../UsageStatistics/Model/MetricValue.php | 37 +++ .../UsageStatistics/Model/ReportMode.php | 16 ++ .../UsageStatistics/Model/ReportPeriod.php | 42 ++++ .../UsageStatistics/Model/UsageReport.php | 22 ++ .../Source/UsageActivityReader.php | 160 +++++++++++++ .../UsageStatistics/UsageReportBuilder.php | 143 +++++++++++ .../UsageStatisticsCollectors.php | 15 ++ .../UsageStatisticsException.php | 16 ++ .../UsageStatistics/UsageStatisticsSchema.php | 151 ++++++++++++ .../Source/UsageActivityReaderTest.php | 191 +++++++++++++++ .../Model/ReportPeriodTest.php | 57 +++++ .../UsageReportBuilderTest.php | 224 ++++++++++++++++++ .../UsageStatisticsSchemaTest.php | 101 ++++++++ 16 files changed, 1267 insertions(+) create mode 100644 lib/Service/UsageStatistics/Contract/IMetricCollector.php create mode 100644 lib/Service/UsageStatistics/Model/CollectedMetric.php create mode 100644 lib/Service/UsageStatistics/Model/MetricDefinition.php create mode 100644 lib/Service/UsageStatistics/Model/MetricValue.php create mode 100644 lib/Service/UsageStatistics/Model/ReportMode.php create mode 100644 lib/Service/UsageStatistics/Model/ReportPeriod.php create mode 100644 lib/Service/UsageStatistics/Model/UsageReport.php create mode 100644 lib/Service/UsageStatistics/Source/UsageActivityReader.php create mode 100644 lib/Service/UsageStatistics/UsageReportBuilder.php create mode 100644 lib/Service/UsageStatistics/UsageStatisticsCollectors.php create mode 100644 lib/Service/UsageStatistics/UsageStatisticsException.php create mode 100644 lib/Service/UsageStatistics/UsageStatisticsSchema.php create mode 100644 tests/php/Integration/Service/UsageStatistics/Source/UsageActivityReaderTest.php create mode 100644 tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php create mode 100644 tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php create mode 100644 tests/php/Unit/Service/UsageStatistics/UsageStatisticsSchemaTest.php diff --git a/lib/Service/UsageStatistics/Contract/IMetricCollector.php b/lib/Service/UsageStatistics/Contract/IMetricCollector.php new file mode 100644 index 0000000000..cf28705473 --- /dev/null +++ b/lib/Service/UsageStatistics/Contract/IMetricCollector.php @@ -0,0 +1,30 @@ + + */ + public function metricIds(): array; + + /** + * Must answer every id from metricIds(), using MetricValue::unavailable() + * when the value cannot be known for the period. + * + * @return array + */ + public function collect(ReportPeriod $period, ReportMode $mode): array; +} diff --git a/lib/Service/UsageStatistics/Model/CollectedMetric.php b/lib/Service/UsageStatistics/Model/CollectedMetric.php new file mode 100644 index 0000000000..42e88dbeb7 --- /dev/null +++ b/lib/Service/UsageStatistics/Model/CollectedMetric.php @@ -0,0 +1,29 @@ + $this->category, + 'key' => $this->key, + 'type' => $this->type, + 'value' => $this->value, + ]; + } +} diff --git a/lib/Service/UsageStatistics/Model/MetricDefinition.php b/lib/Service/UsageStatistics/Model/MetricDefinition.php new file mode 100644 index 0000000000..05af7bb05c --- /dev/null +++ b/lib/Service/UsageStatistics/Model/MetricDefinition.php @@ -0,0 +1,33 @@ +category . '.' . $this->key; + } + + public function isSnapshot(): bool { + return $this->kind === self::KIND_SNAPSHOT; + } +} diff --git a/lib/Service/UsageStatistics/Model/MetricValue.php b/lib/Service/UsageStatistics/Model/MetricValue.php new file mode 100644 index 0000000000..ab30cd778a --- /dev/null +++ b/lib/Service/UsageStatistics/Model/MetricValue.php @@ -0,0 +1,37 @@ +available; + } + + public function value(): int|bool|string|null { + return $this->value; + } +} diff --git a/lib/Service/UsageStatistics/Model/ReportMode.php b/lib/Service/UsageStatistics/Model/ReportMode.php new file mode 100644 index 0000000000..20fd38bee0 --- /dev/null +++ b/lib/Service/UsageStatistics/Model/ReportMode.php @@ -0,0 +1,16 @@ +setTimezone(new \DateTimeZone('UTC')); + $start = $utc->setDate((int)$utc->format('Y'), (int)$utc->format('n'), 1)->setTime(0, 0); + return new self($start, $start->modify('+1 month')); + } + + public function start(): \DateTimeImmutable { + return $this->start; + } + + public function end(): \DateTimeImmutable { + return $this->end; + } + + public function contains(\DateTimeInterface $instant): bool { + return $instant >= $this->start && $instant < $this->end; + } + + public function next(): self { + return new self($this->end, $this->end->modify('+1 month')); + } +} diff --git a/lib/Service/UsageStatistics/Model/UsageReport.php b/lib/Service/UsageStatistics/Model/UsageReport.php new file mode 100644 index 0000000000..6fe8f0c95b --- /dev/null +++ b/lib/Service/UsageStatistics/Model/UsageReport.php @@ -0,0 +1,22 @@ + $metrics + */ + public function __construct( + public readonly string $application, + public readonly int $schemaVersion, + public readonly ReportPeriod $period, + public readonly array $metrics, + ) { + } +} diff --git a/lib/Service/UsageStatistics/Source/UsageActivityReader.php b/lib/Service/UsageStatistics/Source/UsageActivityReader.php new file mode 100644 index 0000000000..b7d099aa41 --- /dev/null +++ b/lib/Service/UsageStatistics/Source/UsageActivityReader.php @@ -0,0 +1,160 @@ +db->getQueryBuilder(); + $qb->select($qb->func()->min('created_at')) + ->from('libresign_file'); + return $this->fetchDateTime($qb); + } + + /** When the first certificate of any type was recorded. */ + public function firstCertificateRecordedAt(): ?\DateTimeImmutable { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->min('issued_at')) + ->from('libresign_crl'); + return $this->fetchDateTime($qb); + } + + public function countStandaloneFilesCreated(ReportPeriod $period): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('libresign_file', 'f') + ->where($qb->expr()->isNull('f.parent_file_id')) + ->andWhere($qb->expr()->neq('f.node_type', $qb->createNamedParameter(NodeType::ENVELOPE->value))) + ->andWhere($this->notExists($qb, $this->childFiles())) + ->andWhere($this->notExists($qb, $this->identificationDocuments('f.id'))); + $this->whereInPeriod($qb, 'f.created_at', $period); + return $this->fetchCount($qb); + } + + public function countEnvelopesCreated(ReportPeriod $period): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('libresign_file', 'f') + ->where($qb->expr()->isNull('f.parent_file_id')) + ->andWhere($qb->expr()->orX( + $qb->expr()->eq('f.node_type', $qb->createNamedParameter(NodeType::ENVELOPE->value)), + $qb->createFunction('EXISTS (' . $this->childFiles()->getSQL() . ')'), + )); + $this->whereInPeriod($qb, 'f.created_at', $period); + return $this->fetchCount($qb); + } + + public function countSigningRequestsCreated(ReportPeriod $period): int { + return $this->countSignRequests($period, 'sr.created_at', ParticipantRole::SIGNER); + } + + public function countSigningRequestsCompleted(ReportPeriod $period): int { + return $this->countSignRequests($period, 'sr.signed', ParticipantRole::SIGNER); + } + + public function countSigningRequestsRejected(ReportPeriod $period): int { + return $this->countSignRequests($period, 'sr.rejected_at', ParticipantRole::SIGNER); + } + + public function countObserversAdded(ReportPeriod $period): int { + return $this->countSignRequests($period, 'sr.created_at', ParticipantRole::OBSERVER); + } + + public function countSigningCertificatesIssued(ReportPeriod $period): int { + return $this->countSigningCertificates($period, 'issued_at'); + } + + public function countSigningCertificatesRevoked(ReportPeriod $period): int { + return $this->countSigningCertificates($period, 'revoked_at'); + } + + private function countSignRequests(ReportPeriod $period, string $eventColumn, ParticipantRole $role): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('libresign_sign_request', 'sr') + ->innerJoin('sr', 'libresign_file', 'f', $qb->expr()->eq('f.id', 'sr.file_id')) + ->where($qb->expr()->isNull('f.parent_file_id')) + ->andWhere($qb->expr()->eq('sr.participant_role', $qb->createNamedParameter($role->value))) + ->andWhere($this->notExists($qb, $this->identificationDocuments('sr.file_id'))); + $this->whereInPeriod($qb, $eventColumn, $period); + return $this->fetchCount($qb); + } + + private function countSigningCertificates(ReportPeriod $period, string $eventColumn): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('libresign_crl') + ->where($qb->expr()->eq('certificate_type', $qb->createNamedParameter(CertificateType::LEAF->value))); + $this->whereInPeriod($qb, $eventColumn, $period); + return $this->fetchCount($qb); + } + + private function childFiles(): IQueryBuilder { + $sub = $this->db->getQueryBuilder(); + return $sub->select($sub->expr()->literal(1)) + ->from('libresign_file', 'child') + ->where($sub->expr()->eq('child.parent_file_id', 'f.id')); + } + + private function identificationDocuments(string $fileIdColumn): IQueryBuilder { + $sub = $this->db->getQueryBuilder(); + return $sub->select($sub->expr()->literal(1)) + ->from('libresign_id_docs', 'idd') + ->where($sub->expr()->eq('idd.file_id', $fileIdColumn)); + } + + private function notExists(IQueryBuilder $outer, IQueryBuilder $subQuery): string { + return (string)$outer->createFunction('NOT EXISTS (' . $subQuery->getSQL() . ')'); + } + + private function whereInPeriod(IQueryBuilder $qb, string $column, ReportPeriod $period): void { + $qb->andWhere($qb->expr()->gte($column, $qb->createNamedParameter($period->start(), IQueryBuilder::PARAM_DATETIME_IMMUTABLE))) + ->andWhere($qb->expr()->lt($column, $qb->createNamedParameter($period->end(), IQueryBuilder::PARAM_DATETIME_IMMUTABLE))); + } + + private function fetchCount(IQueryBuilder $qb): int { + $result = $qb->executeQuery(); + $count = $result->fetchOne(); + $result->closeCursor(); + return (int)$count; + } + + private function fetchDateTime(IQueryBuilder $qb): ?\DateTimeImmutable { + $result = $qb->executeQuery(); + $value = $result->fetchOne(); + $result->closeCursor(); + if (!is_string($value) || $value === '') { + return null; + } + return new \DateTimeImmutable($value, new \DateTimeZone('UTC')); + } +} diff --git a/lib/Service/UsageStatistics/UsageReportBuilder.php b/lib/Service/UsageStatistics/UsageReportBuilder.php new file mode 100644 index 0000000000..9353946b0f --- /dev/null +++ b/lib/Service/UsageStatistics/UsageReportBuilder.php @@ -0,0 +1,143 @@ + $collectorClasses + */ + public function __construct( + private ContainerInterface $container, + private UsageActivityReader $activityReader, + private UsageStatisticsSchema $schema, + private array $collectorClasses = UsageStatisticsCollectors::ALL, + ) { + } + + public function build(ReportPeriod $period, ReportMode $mode): UsageReport { + $values = []; + foreach ($this->collectorsByMetric() as $collector) { + $answers = $collector->collect($period, $mode); + $expected = $collector->metricIds(); + $missing = array_diff($expected, array_keys($answers)); + $unexpected = array_diff(array_keys($answers), $expected); + if ($missing !== [] || $unexpected !== []) { + throw new UsageStatisticsException(sprintf( + 'Collector %s answered unexpected metrics (missing: %s; unexpected: %s)', + $collector::class, + implode(', ', $missing), + implode(', ', $unexpected), + )); + } + $values += $answers; + } + + $metrics = []; + foreach ($this->schema->definitions() as $definition) { + $value = $values[$definition->id()]; + if (!$value instanceof MetricValue) { + throw new UsageStatisticsException('Invalid answer for ' . $definition->id()); + } + $metric = $this->toCollectedMetric($definition, $value, $mode); + if ($metric !== null) { + $metrics[] = $metric; + } + } + + if ($metrics === []) { + throw new UsageStatisticsException('A usage report needs at least one metric'); + } + + return new UsageReport($this->schema->application(), $this->schema->version(), $period, $metrics); + } + + /** The month of the first LibreSign activity, where the historical series starts. */ + public function firstActivityPeriod(): ?ReportPeriod { + $firstActivityAt = $this->activityReader->firstActivityAt(); + return $firstActivityAt === null ? null : ReportPeriod::monthContaining($firstActivityAt); + } + + /** + * Resolves the collectors and checks that every schema metric has exactly one. + * + * @return array keyed by collector class + */ + private function collectorsByMetric(): array { + $collectors = []; + $owners = []; + foreach ($this->collectorClasses as $collectorClass) { + $collector = $this->container->get($collectorClass); + if (!$collector instanceof IMetricCollector) { + throw new UsageStatisticsException('Invalid usage statistics collector: ' . $collectorClass); + } + foreach ($collector->metricIds() as $metricId) { + $this->schema->get($metricId); + if (isset($owners[$metricId])) { + throw new UsageStatisticsException('Metric has more than one collector: ' . $metricId); + } + $owners[$metricId] = $collectorClass; + } + $collectors[$collectorClass] = $collector; + } + + foreach ($this->schema->definitions() as $definition) { + if (!isset($owners[$definition->id()])) { + throw new UsageStatisticsException('Metric has no collector: ' . $definition->id()); + } + } + + return $collectors; + } + + private function toCollectedMetric(MetricDefinition $definition, MetricValue $value, ReportMode $mode): ?CollectedMetric { + if (!$value->isAvailable()) { + if ($definition->required) { + throw new UsageStatisticsException('Required metric is unavailable: ' . $definition->id()); + } + return null; + } + if ($mode === ReportMode::HISTORICAL && $definition->isSnapshot()) { + throw new UsageStatisticsException('A current snapshot cannot be reported for a past period: ' . $definition->id()); + } + + $raw = $value->value(); + $valid = match ($definition->type) { + 'integer' => is_int($raw) && $raw >= 0, + 'boolean' => is_bool($raw), + 'string' => is_string($raw) && $raw !== '' && strlen($raw) <= self::MAX_STRING_BYTES && mb_check_encoding($raw, 'UTF-8'), + default => false, + }; + if (!$valid || $raw === null) { + throw new UsageStatisticsException('Invalid value for ' . $definition->id()); + } + + return new CollectedMetric($definition->category, $definition->key, $definition->type, $raw); + } +} diff --git a/lib/Service/UsageStatistics/UsageStatisticsCollectors.php b/lib/Service/UsageStatistics/UsageStatisticsCollectors.php new file mode 100644 index 0000000000..515e4ea517 --- /dev/null +++ b/lib/Service/UsageStatistics/UsageStatisticsCollectors.php @@ -0,0 +1,15 @@ +> */ + public const ALL = [ + ]; +} diff --git a/lib/Service/UsageStatistics/UsageStatisticsException.php b/lib/Service/UsageStatistics/UsageStatisticsException.php new file mode 100644 index 0000000000..bac5b28e40 --- /dev/null +++ b/lib/Service/UsageStatistics/UsageStatisticsException.php @@ -0,0 +1,16 @@ + */ + private array $definitions = []; + + /** + * @param array|null $data a decoded schema; null reads the one shipped with the app + */ + public function __construct(?array $data = null) { + $data ??= self::readShippedSchema(); + + $unknownFields = array_diff(array_keys($data), ['application', 'schemaVersion', 'metrics']); + if ($unknownFields !== []) { + throw new UsageStatisticsException('Unknown schema field: ' . implode(', ', $unknownFields)); + } + if (($data['application'] ?? null) !== Application::APP_ID) { + throw new UsageStatisticsException('Schema application must be ' . Application::APP_ID); + } + $version = $data['schemaVersion'] ?? null; + if (!is_int($version) || $version < 1) { + throw new UsageStatisticsException('Schema version must be a positive integer'); + } + $metrics = $data['metrics'] ?? null; + if (!is_array($metrics) || !array_is_list($metrics) || $metrics === [] || count($metrics) > self::MAX_METRICS) { + throw new UsageStatisticsException('Schema must define between 1 and ' . self::MAX_METRICS . ' metrics'); + } + + foreach ($metrics as $metric) { + $definition = self::parseMetric($metric); + if (isset($this->definitions[$definition->id()])) { + throw new UsageStatisticsException('Duplicate metric: ' . $definition->id()); + } + $this->definitions[$definition->id()] = $definition; + } + $this->application = Application::APP_ID; + $this->version = $version; + } + + public static function load(): self { + return new self(); + } + + public static function fromArray(array $data): self { + return new self($data); + } + + private static function readShippedSchema(): array { + $path = dirname(__DIR__, 3) . '/appinfo/usage-statistics/schema-v' . self::VERSION . '.json'; + $content = is_readable($path) ? file_get_contents($path) : false; + if ($content === false) { + throw new UsageStatisticsException('Usage statistics schema not found: ' . $path); + } + try { + $data = json_decode($content, true, 16, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new UsageStatisticsException('Usage statistics schema is not valid JSON', 0, $e); + } + if (!is_array($data)) { + throw new UsageStatisticsException('Usage statistics schema must be a JSON object'); + } + return $data; + } + + private static function parseMetric(mixed $metric): MetricDefinition { + if (!is_array($metric)) { + throw new UsageStatisticsException('Each metric must be an object'); + } + $fields = array_keys($metric); + sort($fields); + $expected = self::METRIC_FIELDS; + sort($expected); + if ($fields !== $expected) { + throw new UsageStatisticsException('A metric must have exactly the fields: ' . implode(', ', self::METRIC_FIELDS)); + } + foreach (['category', 'key'] as $name) { + if (!is_string($metric[$name]) || preg_match(self::NAME_PATTERN, $metric[$name]) !== 1) { + throw new UsageStatisticsException('Invalid metric ' . $name); + } + } + if (!in_array($metric['type'], self::TYPES, true) + || !in_array($metric['kind'], self::KINDS, true) + || !in_array($metric['aggregation'], self::AGGREGATIONS, true)) { + throw new UsageStatisticsException('Invalid type, kind or aggregation for ' . $metric['category'] . '.' . $metric['key']); + } + if ($metric['aggregation'] === 'numerical' && !in_array($metric['type'], ['integer', 'number'], true)) { + throw new UsageStatisticsException('Numerical aggregation requires a numeric type'); + } + if (!is_string($metric['description']) || strlen($metric['description']) > self::MAX_DESCRIPTION_BYTES) { + throw new UsageStatisticsException('Invalid metric description'); + } + if (!is_bool($metric['required'])) { + throw new UsageStatisticsException('Metric required flag must be a boolean'); + } + + return new MetricDefinition( + $metric['category'], + $metric['key'], + $metric['type'], + $metric['kind'], + $metric['aggregation'], + $metric['description'], + $metric['required'], + ); + } + + public function application(): string { + return $this->application; + } + + public function version(): int { + return $this->version; + } + + /** @return list */ + public function definitions(): array { + return array_values($this->definitions); + } + + public function get(string $metricId): MetricDefinition { + return $this->definitions[$metricId] + ?? throw new UsageStatisticsException('Unknown usage statistics metric: ' . $metricId); + } +} diff --git a/tests/php/Integration/Service/UsageStatistics/Source/UsageActivityReaderTest.php b/tests/php/Integration/Service/UsageStatistics/Source/UsageActivityReaderTest.php new file mode 100644 index 0000000000..63e883de5f --- /dev/null +++ b/tests/php/Integration/Service/UsageStatistics/Source/UsageActivityReaderTest.php @@ -0,0 +1,191 @@ +fileMapper = Server::get(FileMapper::class); + $this->signRequestMapper = Server::get(SignRequestMapper::class); + $this->crlMapper = Server::get(CrlMapper::class); + $this->reader = Server::get(UsageActivityReader::class); + $this->deleteCertificates(); + } + + public function tearDown(): void { + $this->deleteCertificates(); + parent::tearDown(); + } + + public function testNothingRecordedYet(): void { + $this->assertNull($this->reader->firstActivityAt()); + $this->assertNull($this->reader->firstCertificateRecordedAt()); + $this->assertSame(0, $this->reader->countStandaloneFilesCreated($this->august())); + } + + public function testCountsEachEventOnceInsideTheStartInclusiveEndExclusivePeriod(): void { + $this->seedActivity(); + + $august = $this->august(); + $this->assertSame(1, $this->reader->countStandaloneFilesCreated($august), 'files'); + $this->assertSame(2, $this->reader->countEnvelopesCreated($august), 'envelopes'); + $this->assertSame(4, $this->reader->countSigningRequestsCreated($august), 'requests'); + $this->assertSame(3, $this->reader->countSigningRequestsCompleted($august), 'completed'); + $this->assertSame(0, $this->reader->countSigningRequestsRejected($august), 'rejected'); + $this->assertSame(1, $this->reader->countObserversAdded($august), 'observers'); + + $september = $august->next(); + $this->assertSame(1, $this->reader->countStandaloneFilesCreated($september), 'files in September'); + $this->assertSame(1, $this->reader->countSigningRequestsRejected($september), 'rejected in September'); + } + + public function testAPeriodWithoutActivityCountsZero(): void { + $this->seedActivity(); + + $october = $this->august()->next()->next(); + $this->assertSame(0, $this->reader->countStandaloneFilesCreated($october)); + $this->assertSame(0, $this->reader->countEnvelopesCreated($october)); + $this->assertSame(0, $this->reader->countSigningRequestsCreated($october)); + $this->assertSame(0, $this->reader->countSigningRequestsCompleted($october)); + $this->assertSame(0, $this->reader->countObserversAdded($october)); + $this->assertSame(0, $this->reader->countSigningCertificatesIssued($october)); + } + + public function testTheFirstActivityIsTheOldestLibreSignFile(): void { + $this->seedActivity(); + + $this->assertSame('2026-07-31 23:59:59', $this->reader->firstActivityAt()?->format('Y-m-d H:i:s')); + } + + public function testOnlySigningCertificatesAreCounted(): void { + $this->insertCertificate(CertificateType::ROOT, '2026-08-01 08:00:00'); + $this->insertCertificate(CertificateType::LEAF, '2026-08-02 10:00:00'); + $this->insertCertificate(CertificateType::LEAF, '2026-08-20 10:00:00', '2026-08-25 10:00:00'); + $this->insertCertificate(CertificateType::LEAF, '2026-09-01 00:00:00'); + $this->insertCertificate(CertificateType::INTERMEDIATE, '2026-08-03 00:00:00', '2026-08-04 00:00:00'); + + $this->assertSame(2, $this->reader->countSigningCertificatesIssued($this->august())); + $this->assertSame(1, $this->reader->countSigningCertificatesRevoked($this->august())); + $this->assertSame('2026-08-01 08:00:00', $this->reader->firstCertificateRecordedAt()?->format('Y-m-d H:i:s')); + } + + /** + * August 2026: one standalone file, one envelope with two files, one + * envelope stored before node_type existed, one identification document. + */ + private function seedActivity(): void { + $standalone = $this->insertFile('2026-08-01 00:00:00'); + $this->insertSignRequest($standalone, '2026-08-01 00:00:00', signed: '2026-08-31 23:59:59'); + $this->insertSignRequest($standalone, '2026-08-05 00:00:00', rejected: '2026-09-01 00:00:00'); + $this->insertSignRequest($standalone, '2026-08-05 00:00:00', role: ParticipantRole::OBSERVER); + + $this->insertFile('2026-09-01 00:00:00'); + $this->insertFile('2026-07-31 23:59:59'); + + $envelope = $this->insertFile('2026-08-10 00:00:00', NodeType::ENVELOPE); + foreach (['2026-08-10 00:00:01', '2026-08-10 00:00:02'] as $createdAt) { + $child = $this->insertFile($createdAt, NodeType::FILE, $envelope); + $this->insertSignRequest($child, $createdAt, signed: '2026-08-12 00:00:00'); + $this->insertSignRequest($child, $createdAt, signed: '2026-08-12 00:00:00'); + } + $this->insertSignRequest($envelope, '2026-08-10 00:00:00', signed: '2026-08-12 00:00:00'); + $this->insertSignRequest($envelope, '2026-08-10 00:00:00', signed: '2026-08-12 00:00:00'); + + $legacyEnvelope = $this->insertFile('2026-08-11 00:00:00'); + $this->insertFile('2026-08-11 00:00:01', NodeType::FILE, $legacyEnvelope); + + $identificationDocument = $this->insertFile('2026-08-15 00:00:00'); + $idDocSignRequest = $this->insertSignRequest($identificationDocument, '2026-08-15 00:00:00', signed: '2026-08-16 00:00:00'); + Server::get(IdDocsMapper::class)->save($identificationDocument->getId(), $idDocSignRequest->getId(), 'usage-user', 'IDENTIFICATION'); + } + + private function insertFile(string $createdAt, NodeType $nodeType = NodeType::FILE, ?File $parent = null): File { + $file = new File(); + $file->setNodeId(900000 + ++$this->sequence); + $file->setUserId('usage-user'); + $file->setUuid(sprintf('00000000-0000-4000-8000-%012d', $this->sequence)); + $file->setCreatedAt(new \DateTime($createdAt, new \DateTimeZone('UTC'))); + $file->setName('usage-' . $this->sequence . '.pdf'); + $file->setStatus(FileStatus::DRAFT->value); + $file->setNodeTypeEnum($nodeType); + $file->setParentFileId($parent?->getId()); + return $this->fileMapper->insert($file); + } + + private function insertSignRequest( + File $file, + string $createdAt, + ?string $signed = null, + ?string $rejected = null, + ParticipantRole $role = ParticipantRole::SIGNER, + ): SignRequest { + $utc = new \DateTimeZone('UTC'); + $signRequest = new SignRequest(); + $signRequest->setFileId($file->getId()); + $signRequest->setDisplayName('Signer ' . ++$this->sequence); + $signRequest->setUuid(sprintf('11111111-0000-4000-8000-%012d', $this->sequence)); + $signRequest->setCreatedAt(new \DateTime($createdAt, $utc)); + $signRequest->setParticipantRole($role->value); + if ($signed !== null) { + $signRequest->setSigned(new \DateTime($signed, $utc)); + } + if ($rejected !== null) { + $signRequest->setRejectedAt(new \DateTime($rejected, $utc)); + } + return $this->signRequestMapper->insert($signRequest); + } + + private function insertCertificate(CertificateType $type, string $issuedAt, ?string $revokedAt = null): void { + $utc = new \DateTimeZone('UTC'); + $certificate = new Crl(); + $certificate->setSerialNumber((string)(700000 + ++$this->sequence)); + $certificate->setOwner('usage-user'); + $certificate->setStatus($revokedAt === null ? CRLStatus::ISSUED : CRLStatus::REVOKED); + $certificate->setIssuedAt(new \DateTime($issuedAt, $utc)); + $certificate->setCertificateType($type->value); + if ($revokedAt !== null) { + $certificate->setRevokedAt(new \DateTime($revokedAt, $utc)); + } + $this->crlMapper->insert($certificate); + } + + private function august(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-15T00:00:00Z')); + } + + private function deleteCertificates(): void { + Server::get(IDBConnection::class)->getQueryBuilder()->delete('libresign_crl')->executeStatement(); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php b/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php new file mode 100644 index 0000000000..5b60a19d48 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php @@ -0,0 +1,57 @@ +assertSame($start, $period->start()->format(\DateTimeInterface::RFC3339)); + $this->assertSame($end, $period->end()->format(\DateTimeInterface::RFC3339)); + } + + public static function instantsProvider(): array { + return [ + 'middle of the month' => ['2026-08-15T10:00:00+00:00', '2026-08-01T00:00:00+00:00', '2026-09-01T00:00:00+00:00'], + 'first instant belongs to the month' => ['2026-08-01T00:00:00+00:00', '2026-08-01T00:00:00+00:00', '2026-09-01T00:00:00+00:00'], + 'last instant before the next month' => ['2026-08-31T23:59:59+00:00', '2026-08-01T00:00:00+00:00', '2026-09-01T00:00:00+00:00'], + 'local time is converted to UTC first' => ['2026-09-01T01:00:00+03:00', '2026-08-01T00:00:00+00:00', '2026-09-01T00:00:00+00:00'], + 'december rolls over the year' => ['2025-12-20T00:00:00+00:00', '2025-12-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00'], + ]; + } + + public function testBoundariesAreStartInclusiveAndEndExclusive(): void { + $period = ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-10T00:00:00Z')); + + $this->assertTrue($period->contains(new \DateTimeImmutable('2026-08-01T00:00:00Z'))); + $this->assertTrue($period->contains(new \DateTimeImmutable('2026-08-31T23:59:59Z'))); + $this->assertFalse($period->contains(new \DateTimeImmutable('2026-09-01T00:00:00Z'))); + $this->assertFalse($period->contains(new \DateTimeImmutable('2026-07-31T23:59:59Z'))); + } + + public function testNextReturnsTheFollowingMonth(): void { + $period = ReportPeriod::monthContaining(new \DateTimeImmutable('2026-12-05T00:00:00Z'))->next(); + + $this->assertSame('2027-01-01T00:00:00+00:00', $period->start()->format(\DateTimeInterface::RFC3339)); + $this->assertSame('2027-02-01T00:00:00+00:00', $period->end()->format(\DateTimeInterface::RFC3339)); + } + + public function testBoundariesAreImmutable(): void { + $period = ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-10T00:00:00Z')); + + $period->start()->modify('+1 day'); + + $this->assertSame('2026-08-01T00:00:00+00:00', $period->start()->format(\DateTimeInterface::RFC3339)); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php b/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php new file mode 100644 index 0000000000..2e8a767433 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php @@ -0,0 +1,224 @@ +activityReader = $this->createMock(UsageActivityReader::class); + } + + public function testBuildsTheMetricsInSchemaOrder(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(3), 'php.version' => MetricValue::of('8.3')]), + ]); + + $report = $builder->build($this->period(), ReportMode::CURRENT); + + $this->assertSame('libresign', $report->application); + $this->assertSame(1, $report->schemaVersion); + $this->assertSame([ + ['category' => 'php', 'key' => 'version', 'type' => 'string', 'value' => '8.3'], + ['category' => 'documents', 'key' => 'files_created', 'type' => 'integer', 'value' => 3], + ], array_map(static fn ($metric): array => $metric->toArray(), $report->metrics)); + } + + public function testAZeroCounterIsReportedAsAKnownZero(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(0), 'php.version' => MetricValue::unavailable()]), + ]); + + $report = $builder->build($this->period(), ReportMode::HISTORICAL); + + $this->assertCount(1, $report->metrics); + $this->assertSame(0, $report->metrics[0]->value); + } + + public function testAnUnavailableOptionalMetricIsOmitted(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::unavailable()]), + ]); + + $report = $builder->build($this->period(), ReportMode::CURRENT); + + $this->assertSame(['files_created'], array_map(static fn ($metric): string => $metric->key, $report->metrics)); + } + + public function testAHistoricalReportNeverCarriesASnapshot(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of('8.3')]), + ]); + + $this->expectException(UsageStatisticsException::class); + $this->expectExceptionMessage('php.version'); + + $builder->build($this->period(), ReportMode::HISTORICAL); + } + + #[DataProvider('unsafeCollectorAnswersProvider')] + public function testAnUnsafeCollectorAnswerFailsTheWholeReport(array $answers): void { + $builder = $this->builder($this->schema(), [$this->collector($answers, ['documents.files_created', 'php.version'])]); + + $this->expectException(UsageStatisticsException::class); + + $builder->build($this->period(), ReportMode::CURRENT); + } + + public static function unsafeCollectorAnswersProvider(): array { + $string = MetricValue::of('8.3'); + return [ + 'a metric is not answered' => [['documents.files_created' => MetricValue::of(1)]], + 'an unexpected metric is answered' => [['documents.files_created' => MetricValue::of(1), 'php.version' => $string, 'php.extra' => $string]], + 'a required metric is unavailable' => [['documents.files_created' => MetricValue::unavailable(), 'php.version' => $string]], + 'an integer is a string' => [['documents.files_created' => MetricValue::of('1'), 'php.version' => $string]], + 'an integer is negative' => [['documents.files_created' => MetricValue::of(-1), 'php.version' => $string]], + 'a string is an integer' => [['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of(8)]], + 'a string is empty' => [['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of('')]], + 'a string is too long' => [['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of(str_repeat('a', 1025))]], + 'a string is not UTF-8' => [['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of("\xff")]], + 'an answer is not a metric value' => [['documents.files_created' => 1, 'php.version' => $string]], + ]; + } + + public function testASchemaMetricWithoutCollectorIsRejected(): void { + $builder = $this->builder($this->schema(), [$this->collector(['documents.files_created' => MetricValue::of(1)])]); + + $this->expectException(UsageStatisticsException::class); + $this->expectExceptionMessage('php.version'); + + $builder->build($this->period(), ReportMode::CURRENT); + } + + public function testAMetricWithTwoCollectorsIsRejected(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of('8.3')]), + $this->collector(['php.version' => MetricValue::of('8.3')]), + ]); + + $this->expectException(UsageStatisticsException::class); + $this->expectExceptionMessage('php.version'); + + $builder->build($this->period(), ReportMode::CURRENT); + } + + public function testACollectorMetricOutsideTheSchemaIsRejected(): void { + $builder = $this->builder($this->schema(), [ + $this->collector(['documents.files_created' => MetricValue::of(1), 'php.version' => MetricValue::of('8.3'), 'php.extensions' => MetricValue::of(3)]), + ]); + + $this->expectException(UsageStatisticsException::class); + $this->expectExceptionMessage('php.extensions'); + + $builder->build($this->period(), ReportMode::CURRENT); + } + + public function testAReportWithoutAnyMetricIsRejected(): void { + $schema = UsageStatisticsSchema::fromArray([ + 'application' => 'libresign', + 'schemaVersion' => 1, + 'metrics' => [$this->metric('php', 'version', 'string', 'snapshot', 'distribution', false)], + ]); + $builder = $this->builder($schema, [$this->collector(['php.version' => MetricValue::unavailable()])]); + + $this->expectException(UsageStatisticsException::class); + + $builder->build($this->period(), ReportMode::HISTORICAL); + } + + public function testTheFirstActivityPeriodIsTheMonthOfTheFirstLibreSignFile(): void { + $this->activityReader->method('firstActivityAt')->willReturn(new \DateTimeImmutable('2025-03-17T08:00:00Z')); + + $period = $this->builder($this->schema(), [])->firstActivityPeriod(); + + $this->assertSame('2025-03-01T00:00:00+00:00', $period?->start()->format(\DateTimeInterface::RFC3339)); + } + + public function testThereIsNoFirstActivityPeriodBeforeAnyActivity(): void { + $this->activityReader->method('firstActivityAt')->willReturn(null); + + $this->assertNull($this->builder($this->schema(), [])->firstActivityPeriod()); + } + + private function period(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-10T00:00:00Z')); + } + + private function schema(): UsageStatisticsSchema { + return UsageStatisticsSchema::fromArray([ + 'application' => 'libresign', + 'schemaVersion' => 1, + 'metrics' => [ + $this->metric('php', 'version', 'string', 'snapshot', 'distribution', false), + $this->metric('documents', 'files_created', 'integer', 'period', 'numerical', true), + ], + ]); + } + + private function metric(string $category, string $key, string $type, string $kind, string $aggregation, bool $required): array { + return [ + 'category' => $category, + 'key' => $key, + 'type' => $type, + 'kind' => $kind, + 'aggregation' => $aggregation, + 'description' => $key, + 'required' => $required, + ]; + } + + /** + * @param array $answers + * @param list|null $metricIds defaults to the answered ids + */ + private function collector(array $answers, ?array $metricIds = null): IMetricCollector { + return new class($answers, $metricIds ?? array_keys($answers)) implements IMetricCollector { + public function __construct( + private array $answers, + private array $metricIds, + ) { + } + + public function metricIds(): array { + return $this->metricIds; + } + + public function collect(ReportPeriod $period, ReportMode $mode): array { + return $this->answers; + } + }; + } + + /** + * @param list $collectors + */ + private function builder(UsageStatisticsSchema $schema, array $collectors): UsageReportBuilder { + $byClass = []; + foreach ($collectors as $index => $collector) { + $byClass['collector' . $index] = $collector; + } + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback(static fn (string $id) => $byClass[$id]); + + return new UsageReportBuilder($container, $this->activityReader, $schema, array_keys($byClass)); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/UsageStatisticsSchemaTest.php b/tests/php/Unit/Service/UsageStatistics/UsageStatisticsSchemaTest.php new file mode 100644 index 0000000000..31c56ef78f --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/UsageStatisticsSchemaTest.php @@ -0,0 +1,101 @@ +assertSame('libresign', $schema->application()); + $this->assertSame(1, $schema->version()); + $this->assertNotEmpty($schema->definitions()); + } + + public function testTheShippedSchemaFollowsTheFixedRulesOfVersionOne(): void { + foreach (UsageStatisticsSchema::load()->definitions() as $definition) { + if ($definition->isSnapshot()) { + $expectedAggregation = $definition->type === 'integer' ? 'numerical' : 'distribution'; + $this->assertSame($expectedAggregation, $definition->aggregation, $definition->id()); + $this->assertFalse($definition->required, $definition->id() . ' must be optional: historical reports omit snapshots'); + continue; + } + $this->assertSame('period', $definition->kind, $definition->id()); + $this->assertSame('integer', $definition->type, $definition->id()); + $this->assertSame('numerical', $definition->aggregation, $definition->id()); + } + } + + public function testTheShippedSchemaHasTheCategoriesOfTheIssue(): void { + $categories = array_values(array_unique(array_map( + static fn ($definition): string => $definition->category, + UsageStatisticsSchema::load()->definitions(), + ))); + + $this->assertSame( + ['nextcloud', 'php', 'database', 'system', 'libresign', 'certificate', 'documents', 'signatures', 'participants'], + $categories, + ); + } + + #[DataProvider('invalidSchemasProvider')] + public function testInvalidSchemasAreRejected(array $schema): void { + $this->expectException(UsageStatisticsException::class); + + UsageStatisticsSchema::fromArray($schema); + } + + public static function invalidSchemasProvider(): array { + $metric = [ + 'category' => 'documents', + 'key' => 'files_created', + 'type' => 'integer', + 'kind' => 'period', + 'aggregation' => 'numerical', + 'description' => 'Files', + 'required' => true, + ]; + $schema = static fn (array $metrics, array $extra = []): array => array_merge( + ['application' => 'libresign', 'schemaVersion' => 1, 'metrics' => $metrics], + $extra, + ); + return [ + 'unknown top-level field' => [$schema([$metric], ['source' => 'x'])], + 'unknown metric field' => [$schema([array_merge($metric, ['source' => 'SignRequest.createdAt'])])], + 'missing metric field' => [$schema([array_diff_key($metric, ['kind' => true])])], + 'no metrics' => [$schema([])], + 'wrong application' => [array_merge($schema([$metric]), ['application' => 'other'])], + 'schema version below one' => [array_merge($schema([$metric]), ['schemaVersion' => 0])], + 'key with a slash' => [$schema([array_merge($metric, ['key' => 'files/created'])])], + 'unknown type' => [$schema([array_merge($metric, ['type' => 'float'])])], + 'unknown kind' => [$schema([array_merge($metric, ['kind' => 'gauge'])])], + 'numerical string' => [$schema([array_merge($metric, ['type' => 'string', 'kind' => 'snapshot'])])], + 'required is not a boolean' => [$schema([array_merge($metric, ['required' => 'yes'])])], + 'description too long' => [$schema([array_merge($metric, ['description' => str_repeat('a', 513)])])], + 'duplicate metric' => [$schema([$metric, $metric])], + ]; + } + + public function testDefinitionsAreIndexedByCategoryAndKey(): void { + $definition = UsageStatisticsSchema::load()->get('signatures.completed'); + + $this->assertSame('signatures', $definition->category); + $this->assertSame('completed', $definition->key); + } + + public function testAnUnknownMetricIsRejected(): void { + $this->expectException(UsageStatisticsException::class); + + UsageStatisticsSchema::load()->get('signatures.unknown'); + } +} From df894044b1bce94a723f081797bcbae4717988b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Mon, 5 Oct 2026 21:57:22 -0300 Subject: [PATCH 3/5] feat(usage-statistics): collect the schema v1 metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers one collector for each group of schema v1 metrics. A test checks that every schema metric has exactly one collector. - Environment and LibreSign configuration snapshots are reported only for the current period, as coarse values: versions reduced to major.minor (major.minor.patch for Nextcloud), the architecture normalized, unknown settings reported as "other". They are read from stored settings without building the certificate engine, so a report never creates the PKI directory or contacts CFSSL. - Document and signing counters are reported for every period. Rejections, observers and envelopes arrived with the columns that record them, so earlier months are a known zero. - Certificates are recorded only since LibreSign 12.1/12.2. Their counters are reported for every month on a fresh install, and on an upgraded instance only for months that start after the first recorded certificate. Part of #8932 Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: André Maia --- .../CertificateActivityCollector.php | 60 ++++++ .../Collector/DocumentActivityCollector.php | 39 ++++ .../Collector/EnvironmentCollector.php | 127 ++++++++++++ .../LibreSignConfigurationCollector.php | 88 ++++++++ .../Collector/SigningActivityCollector.php | 46 +++++ .../Source/RuntimeEnvironment.php | 74 +++++++ .../UsageStatisticsCollectors.php | 11 + .../UsageReportBuilderTest.php | 57 +++++ .../CertificateActivityCollectorTest.php | 92 +++++++++ .../DocumentActivityCollectorTest.php | 39 ++++ .../Collector/EnvironmentCollectorTest.php | 194 ++++++++++++++++++ .../LibreSignConfigurationCollectorTest.php | 130 ++++++++++++ .../SigningActivityCollectorTest.php | 44 ++++ .../UsageReportBuilderTest.php | 21 ++ 14 files changed, 1022 insertions(+) create mode 100644 lib/Service/UsageStatistics/Collector/CertificateActivityCollector.php create mode 100644 lib/Service/UsageStatistics/Collector/DocumentActivityCollector.php create mode 100644 lib/Service/UsageStatistics/Collector/EnvironmentCollector.php create mode 100644 lib/Service/UsageStatistics/Collector/LibreSignConfigurationCollector.php create mode 100644 lib/Service/UsageStatistics/Collector/SigningActivityCollector.php create mode 100644 lib/Service/UsageStatistics/Source/RuntimeEnvironment.php create mode 100644 tests/php/Integration/Service/UsageStatistics/UsageReportBuilderTest.php create mode 100644 tests/php/Unit/Service/UsageStatistics/Collector/CertificateActivityCollectorTest.php create mode 100644 tests/php/Unit/Service/UsageStatistics/Collector/DocumentActivityCollectorTest.php create mode 100644 tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php create mode 100644 tests/php/Unit/Service/UsageStatistics/Collector/LibreSignConfigurationCollectorTest.php create mode 100644 tests/php/Unit/Service/UsageStatistics/Collector/SigningActivityCollectorTest.php diff --git a/lib/Service/UsageStatistics/Collector/CertificateActivityCollector.php b/lib/Service/UsageStatistics/Collector/CertificateActivityCollector.php new file mode 100644 index 0000000000..d08e28d802 --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/CertificateActivityCollector.php @@ -0,0 +1,60 @@ +isHistoryComplete($period)) { + return [ + 'certificate.certificates_issued' => MetricValue::unavailable(), + 'certificate.certificates_revoked' => MetricValue::unavailable(), + ]; + } + return [ + 'certificate.certificates_issued' => MetricValue::of($this->reader->countSigningCertificatesIssued($period)), + 'certificate.certificates_revoked' => MetricValue::of($this->reader->countSigningCertificatesRevoked($period)), + ]; + } + + private function isHistoryComplete(ReportPeriod $period): bool { + $firstActivityAt = $this->reader->firstActivityAt(); + $firstCertificateAt = $this->reader->firstCertificateRecordedAt(); + if ($firstCertificateAt === null) { + return $firstActivityAt === null; + } + if ($firstActivityAt === null || $firstCertificateAt <= $firstActivityAt) { + return true; + } + return $period->start() >= $firstCertificateAt; + } +} diff --git a/lib/Service/UsageStatistics/Collector/DocumentActivityCollector.php b/lib/Service/UsageStatistics/Collector/DocumentActivityCollector.php new file mode 100644 index 0000000000..438cefc9ed --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/DocumentActivityCollector.php @@ -0,0 +1,39 @@ + MetricValue::of($this->reader->countStandaloneFilesCreated($period)), + 'documents.envelopes_created' => MetricValue::of($this->reader->countEnvelopesCreated($period)), + ]; + } +} diff --git a/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php b/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php new file mode 100644 index 0000000000..7525cd8aff --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php @@ -0,0 +1,127 @@ +metricIds(), MetricValue::unavailable()); + } + + $usersTotal = $this->userManager->countUsersTotal(); + $databaseVersion = $this->databaseVersion(); + return [ + 'nextcloud.version' => MetricValue::of($this->runtime->nextcloudVersion()), + // Some user backends cannot count their users. + 'nextcloud.users_total' => $usersTotal === false ? MetricValue::unavailable() : MetricValue::of($usersTotal), + 'nextcloud.users_active_30d' => MetricValue::of($this->countRecentlyActiveUsers()), + 'nextcloud.background_job_mode' => MetricValue::of($this->backgroundJobMode()), + 'php.version' => MetricValue::of($this->runtime->phpVersion()), + 'database.type' => MetricValue::of($this->runtime->databasePlatform()), + 'database.version' => $databaseVersion === null ? MetricValue::unavailable() : MetricValue::of($databaseVersion), + 'system.os_family' => MetricValue::of($this->runtime->osFamily()), + 'system.architecture' => MetricValue::of($this->architecture()), + ]; + } + + /** + * Users come sorted by their last login, most recent first, so counting + * stops at the first one outside the window. + */ + private function countRecentlyActiveUsers(): int { + $since = $this->timeFactory->getTime() - self::ACTIVE_USERS_DAYS * 86400; + $count = 0; + $offset = 0; + do { + $userIds = $this->userManager->getLastLoggedInUsers(self::LAST_LOGIN_BATCH_SIZE, $offset); + foreach ($userIds as $userId) { + $user = $this->userManager->get($userId); + if ($user === null) { + continue; + } + if ($user->getLastLogin() < $since) { + return $count; + } + $count++; + } + $offset += self::LAST_LOGIN_BATCH_SIZE; + } while (count($userIds) === self::LAST_LOGIN_BATCH_SIZE); + return $count; + } + + private function backgroundJobMode(): string { + $mode = $this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax'); + return in_array($mode, self::BACKGROUND_JOB_MODES, true) ? $mode : 'other'; + } + + /** + * Only major.minor: full version strings carry build and distribution details. + */ + private function databaseVersion(): ?string { + $version = $this->runtime->databaseServerVersion(); + if ($version === null) { + return null; + } + if (preg_match('/^(\d+)\.(\d+)/', trim($version), $matches) !== 1) { + throw new UsageStatisticsException('Unrecognized database version format'); + } + return $matches[1] . '.' . $matches[2]; + } + + private function architecture(): string { + try { + return InstallTarget::normalizeArchitecture($this->runtime->machine()); + } catch (\InvalidArgumentException) { + return 'other'; + } + } +} diff --git a/lib/Service/UsageStatistics/Collector/LibreSignConfigurationCollector.php b/lib/Service/UsageStatistics/Collector/LibreSignConfigurationCollector.php new file mode 100644 index 0000000000..e865f9d381 --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/LibreSignConfigurationCollector.php @@ -0,0 +1,88 @@ +metricIds(), MetricValue::unavailable()); + } + + $certificateEngine = $this->certificateEngine(); + return [ + 'libresign.version' => MetricValue::of($this->appManager->getAppVersion(Application::APP_ID)), + 'libresign.signature_engine' => MetricValue::of($this->signatureEngine()), + 'libresign.signing_mode' => MetricValue::of($this->signingMode()), + 'certificate.engine' => MetricValue::of($certificateEngine), + 'certificate.root_ca_configured' => MetricValue::of($this->isRootCaConfigured($certificateEngine)), + ]; + } + + /** Same fallback as the admin settings: anything but PhpNative runs JSignPdf. */ + private function signatureEngine(): string { + $engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf'); + return $engine === 'PhpNative' ? $engine : 'JSignPdf'; + } + + /** Same fallback as the signing mode policy. */ + private function signingMode(): string { + $mode = $this->appConfig->getValueString(Application::APP_ID, 'signing_mode', 'sync'); + return in_array($mode, ['sync', 'async'], true) ? $mode : 'sync'; + } + + private function certificateEngine(): string { + $engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl'); + return in_array($engine, self::CERTIFICATE_ENGINES, true) ? $engine : 'other'; + } + + private function isRootCaConfigured(string $certificateEngine): bool { + if (!in_array($certificateEngine, ['openssl', 'cfssl'], true)) { + return false; + } + $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path'); + if ($configPath === '' || !is_dir($configPath)) { + return false; + } + return file_exists($configPath . DIRECTORY_SEPARATOR . 'ca.pem') + && file_exists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem'); + } +} diff --git a/lib/Service/UsageStatistics/Collector/SigningActivityCollector.php b/lib/Service/UsageStatistics/Collector/SigningActivityCollector.php new file mode 100644 index 0000000000..8d9d1ca24f --- /dev/null +++ b/lib/Service/UsageStatistics/Collector/SigningActivityCollector.php @@ -0,0 +1,46 @@ + MetricValue::of($this->reader->countSigningRequestsCreated($period)), + 'signatures.completed' => MetricValue::of($this->reader->countSigningRequestsCompleted($period)), + 'signatures.rejected' => MetricValue::of($this->reader->countSigningRequestsRejected($period)), + 'participants.observers_added' => MetricValue::of($this->reader->countObserversAdded($period)), + ]; + } +} diff --git a/lib/Service/UsageStatistics/Source/RuntimeEnvironment.php b/lib/Service/UsageStatistics/Source/RuntimeEnvironment.php new file mode 100644 index 0000000000..c0f3c50d11 --- /dev/null +++ b/lib/Service/UsageStatistics/Source/RuntimeEnvironment.php @@ -0,0 +1,74 @@ +serverVersion->getMajorVersion(), + $this->serverVersion->getMinorVersion(), + $this->serverVersion->getPatchVersion(), + ); + } + + public function phpVersion(): string { + return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + } + + public function osFamily(): string { + return PHP_OS_FAMILY; + } + + /** Machine hardware name only; never the hostname or the kernel string. */ + public function machine(): string { + return php_uname('m'); + } + + /** + * @return IDBConnection::PLATFORM_* + */ + public function databasePlatform(): string { + return $this->db->getDatabaseProvider(true); + } + + /** + * The server version string as reported by the database, or null when the + * platform offers no portable way to read it. + */ + public function databaseServerVersion(): ?string { + $query = match ($this->databasePlatform()) { + IDBConnection::PLATFORM_MYSQL, IDBConnection::PLATFORM_MARIADB => 'SELECT VERSION()', + IDBConnection::PLATFORM_POSTGRES => 'SHOW server_version', + IDBConnection::PLATFORM_SQLITE => 'SELECT sqlite_version()', + default => null, + }; + if ($query === null) { + return null; + } + $result = $this->db->executeQuery($query); + $version = $result->fetchOne(); + $result->closeCursor(); + return is_string($version) ? $version : null; + } +} diff --git a/lib/Service/UsageStatistics/UsageStatisticsCollectors.php b/lib/Service/UsageStatistics/UsageStatisticsCollectors.php index 515e4ea517..721ea4134d 100644 --- a/lib/Service/UsageStatistics/UsageStatisticsCollectors.php +++ b/lib/Service/UsageStatistics/UsageStatisticsCollectors.php @@ -8,8 +8,19 @@ namespace OCA\Libresign\Service\UsageStatistics; +use OCA\Libresign\Service\UsageStatistics\Collector\CertificateActivityCollector; +use OCA\Libresign\Service\UsageStatistics\Collector\DocumentActivityCollector; +use OCA\Libresign\Service\UsageStatistics\Collector\EnvironmentCollector; +use OCA\Libresign\Service\UsageStatistics\Collector\LibreSignConfigurationCollector; +use OCA\Libresign\Service\UsageStatistics\Collector\SigningActivityCollector; + final class UsageStatisticsCollectors { /** @var list> */ public const ALL = [ + EnvironmentCollector::class, + LibreSignConfigurationCollector::class, + CertificateActivityCollector::class, + DocumentActivityCollector::class, + SigningActivityCollector::class, ]; } diff --git a/tests/php/Integration/Service/UsageStatistics/UsageReportBuilderTest.php b/tests/php/Integration/Service/UsageStatistics/UsageReportBuilderTest.php new file mode 100644 index 0000000000..80394d548c --- /dev/null +++ b/tests/php/Integration/Service/UsageStatistics/UsageReportBuilderTest.php @@ -0,0 +1,57 @@ +build($this->period(), ReportMode::CURRENT); + + $reported = array_map(static fn ($metric): string => $metric->category . '.' . $metric->key, $report->metrics); + $this->assertContains('nextcloud.version', $reported); + $this->assertContains('database.type', $reported); + $this->assertContains('database.version', $reported); + $this->assertContains('signatures.requests_created', $reported); + foreach (UsageStatisticsSchema::load()->definitions() as $definition) { + if ($definition->required) { + $this->assertContains($definition->id(), $reported); + } + } + } + + public function testAHistoricalReportHasNoSnapshot(): void { + $report = Server::get(UsageReportBuilder::class)->build($this->period(), ReportMode::HISTORICAL); + + $schema = UsageStatisticsSchema::load(); + foreach ($report->metrics as $metric) { + $this->assertFalse($schema->get($metric->category . '.' . $metric->key)->isSnapshot()); + } + } + + public function testBuildingAReportChangesNothing(): void { + $appConfig = self::getMockAppConfig(); + $before = $appConfig->getAllValues('libresign'); + + Server::get(UsageReportBuilder::class)->build($this->period(), ReportMode::CURRENT); + + $this->assertSame($before, $appConfig->getAllValues('libresign')); + } + + private function period(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-09-15T00:00:00Z')); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/CertificateActivityCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/CertificateActivityCollectorTest.php new file mode 100644 index 0000000000..e0aee0bbdf --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/CertificateActivityCollectorTest.php @@ -0,0 +1,92 @@ +reader = $this->createMock(UsageActivityReader::class); + $this->reader->method('countSigningCertificatesIssued')->willReturn(4); + $this->reader->method('countSigningCertificatesRevoked')->willReturn(1); + } + + #[DataProvider('availabilityProvider')] + public function testCertificatesAreReportedOnlyWhenTheirHistoryIsComplete( + ?string $firstActivityAt, + ?string $firstCertificateAt, + string $periodMonth, + bool $available, + ): void { + $this->reader->method('firstActivityAt')->willReturn($this->date($firstActivityAt)); + $this->reader->method('firstCertificateRecordedAt')->willReturn($this->date($firstCertificateAt)); + + $values = (new CertificateActivityCollector($this->reader))->collect( + ReportPeriod::monthContaining(new \DateTimeImmutable($periodMonth)), + ReportMode::HISTORICAL, + ); + + $this->assertSame($available, $values['certificate.certificates_issued']->isAvailable()); + $this->assertSame($available, $values['certificate.certificates_revoked']->isAvailable()); + if ($available) { + $this->assertSame(4, $values['certificate.certificates_issued']->value()); + $this->assertSame(1, $values['certificate.certificates_revoked']->value()); + } + } + + public static function availabilityProvider(): array { + return [ + 'fresh install: the root CA is older than the first file' => [ + '2026-03-10T12:00:00Z', '2026-03-10T09:00:00Z', '2026-03-01T00:00:00Z', true, + ], + 'certificates recorded since the first activity, later month' => [ + '2026-03-10T12:00:00Z', '2026-03-10T12:00:00Z', '2026-07-01T00:00:00Z', true, + ], + 'upgraded instance: month before the first recorded certificate' => [ + '2024-01-05T00:00:00Z', '2025-10-28T15:00:00Z', '2025-09-01T00:00:00Z', false, + ], + 'upgraded instance: the month of the upgrade is incomplete' => [ + '2024-01-05T00:00:00Z', '2025-10-28T15:00:00Z', '2025-10-01T00:00:00Z', false, + ], + 'upgraded instance: first month that starts after the first record' => [ + '2024-01-05T00:00:00Z', '2025-10-28T15:00:00Z', '2025-11-01T00:00:00Z', true, + ], + 'activity but no certificate recorded at all' => [ + '2024-01-05T00:00:00Z', null, '2026-08-01T00:00:00Z', false, + ], + 'nothing recorded yet: a known zero' => [ + null, null, '2026-08-01T00:00:00Z', true, + ], + ]; + } + + public function testTheCurrentReportUsesTheSameRule(): void { + $this->reader->method('firstActivityAt')->willReturn($this->date('2024-01-05T00:00:00Z')); + $this->reader->method('firstCertificateRecordedAt')->willReturn($this->date('2025-10-28T15:00:00Z')); + + $values = (new CertificateActivityCollector($this->reader))->collect( + ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-01T00:00:00Z')), + ReportMode::CURRENT, + ); + + $this->assertSame(4, $values['certificate.certificates_issued']->value()); + } + + private function date(?string $value): ?\DateTimeImmutable { + return $value === null ? null : new \DateTimeImmutable($value); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/DocumentActivityCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/DocumentActivityCollectorTest.php new file mode 100644 index 0000000000..f90785f008 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/DocumentActivityCollectorTest.php @@ -0,0 +1,39 @@ +createMock(UsageActivityReader::class); + $reader->expects($this->once())->method('countStandaloneFilesCreated')->with($period)->willReturn(7); + $reader->expects($this->once())->method('countEnvelopesCreated')->with($period)->willReturn(0); + + $values = (new DocumentActivityCollector($reader))->collect($period, $mode); + + $this->assertSame(7, $values['documents.files_created']->value()); + $this->assertTrue($values['documents.envelopes_created']->isAvailable()); + $this->assertSame(0, $values['documents.envelopes_created']->value()); + } + + public static function modesProvider(): array { + return [ + 'current' => [ReportMode::CURRENT], + 'historical' => [ReportMode::HISTORICAL], + ]; + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php new file mode 100644 index 0000000000..4a5b0d8488 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php @@ -0,0 +1,194 @@ +userManager = $this->createMock(IUserManager::class); + $this->userManager->method('countUsersTotal')->willReturn(42); + $this->userManager->method('getLastLoggedInUsers')->willReturn([]); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->timeFactory->method('getTime')->willReturn(self::NOW); + $this->runtime = $this->createMock(RuntimeEnvironment::class); + $this->runtime->method('nextcloudVersion')->willReturn('36.0.1'); + $this->runtime->method('phpVersion')->willReturn('8.3'); + $this->runtime->method('osFamily')->willReturn('Linux'); + $this->runtime->method('machine')->willReturn('x86_64'); + $this->runtime->method('databasePlatform')->willReturn('mariadb'); + } + + public function testAHistoricalReportKnowsNoEnvironmentValue(): void { + $this->userManager->expects($this->never())->method('countUsersTotal'); + $this->runtime->expects($this->never())->method('databaseServerVersion'); + + $values = $this->collector()->collect($this->period(), ReportMode::HISTORICAL); + + $this->assertSame($this->collector()->metricIds(), array_keys($values)); + foreach ($values as $value) { + $this->assertFalse($value->isAvailable()); + } + } + + public function testTheCurrentReportCarriesCoarseEnvironmentValues(): void { + $this->runtime->method('databaseServerVersion')->willReturn('11.4.3-MariaDB-1:11.4.3+maria~ubu2204'); + + $values = $this->values($this->collector()->collect($this->period(), ReportMode::CURRENT)); + + $this->assertSame([ + 'nextcloud.version' => '36.0.1', + 'nextcloud.users_total' => 42, + 'nextcloud.users_active_30d' => 0, + 'nextcloud.background_job_mode' => 'ajax', + 'php.version' => '8.3', + 'database.type' => 'mariadb', + 'database.version' => '11.4', + 'system.os_family' => 'Linux', + 'system.architecture' => 'x86_64', + ], $values); + } + + #[DataProvider('databaseVersionsProvider')] + public function testTheDatabaseVersionKeepsOnlyMajorAndMinor(string $reported, string $expected): void { + $this->runtime->method('databaseServerVersion')->willReturn($reported); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($expected, $values['database.version']->value()); + } + + public static function databaseVersionsProvider(): array { + return [ + 'postgres with distribution details' => ['16.4 (Debian 16.4-1.pgdg120+2)', '16.4'], + 'mysql' => ['8.0.39', '8.0'], + 'sqlite' => ['3.46.1', '3.46'], + ]; + } + + public function testAnUnreadableDatabaseVersionFailsInsteadOfGuessing(): void { + $this->runtime->method('databaseServerVersion')->willReturn('unknown build'); + + $this->expectException(UsageStatisticsException::class); + + $this->collector()->collect($this->period(), ReportMode::CURRENT); + } + + public function testADatabaseWithoutPortableVersionQueryIsUnavailable(): void { + $this->runtime->method('databaseServerVersion')->willReturn(null); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertFalse($values['database.version']->isAvailable()); + } + + public function testAUserBackendThatCannotCountMakesTheTotalUnavailable(): void { + $userManager = $this->createMock(IUserManager::class); + $userManager->method('countUsersTotal')->willReturn(false); + $userManager->method('getLastLoggedInUsers')->willReturn([]); + $this->userManager = $userManager; + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertFalse($values['nextcloud.users_total']->isAvailable()); + } + + public function testActiveUsersStopAtTheFirstLoginOutsideTheWindow(): void { + $lastLogins = [ + 'alice' => self::NOW - 3600, + 'bob' => self::NOW - 29 * 86400, + 'carol' => self::NOW - 31 * 86400, + 'dave' => self::NOW - 1, + ]; + $userManager = $this->createMock(IUserManager::class); + $userManager->method('countUsersTotal')->willReturn(4); + $userManager->method('getLastLoggedInUsers')->willReturn(array_keys($lastLogins)); + $userManager->method('get')->willReturnCallback(function (string $userId) use ($lastLogins): IUser { + $user = $this->createMock(IUser::class); + $user->method('getLastLogin')->willReturn($lastLogins[$userId]); + return $user; + }); + $this->userManager = $userManager; + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame(2, $values['nextcloud.users_active_30d']->value()); + } + + public function testAnUnknownArchitectureIsReportedAsOther(): void { + $runtime = $this->createMock(RuntimeEnvironment::class); + $runtime->method('nextcloudVersion')->willReturn('36.0.1'); + $runtime->method('phpVersion')->willReturn('8.3'); + $runtime->method('osFamily')->willReturn('Linux'); + $runtime->method('machine')->willReturn('riscv64'); + $runtime->method('databasePlatform')->willReturn('sqlite'); + $runtime->method('databaseServerVersion')->willReturn('3.46.1'); + $this->runtime = $runtime; + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame('other', $values['system.architecture']->value()); + } + + #[DataProvider('backgroundJobModesProvider')] + public function testTheBackgroundJobModeIsOneOfTheKnownModes(string $stored, string $expected): void { + $this->runtime->method('databaseServerVersion')->willReturn('8.0.39'); + self::getMockAppConfigWithReset()->setValueString('core', 'backgroundjobs_mode', $stored); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($expected, $values['nextcloud.background_job_mode']->value()); + } + + public static function backgroundJobModesProvider(): array { + return [ + 'cron' => ['cron', 'cron'], + 'webcron' => ['webcron', 'webcron'], + 'unexpected value' => ['https://cron.example.com/secret', 'other'], + ]; + } + + private function collector(): EnvironmentCollector { + return new EnvironmentCollector( + $this->userManager, + self::getMockAppConfig(), + $this->timeFactory, + $this->runtime, + ); + } + + private function period(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-09-15T00:00:00Z')); + } + + /** + * @param array $values + * @return array + */ + private function values(array $values): array { + return array_map(static fn (MetricValue $value) => $value->value(), $values); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/LibreSignConfigurationCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/LibreSignConfigurationCollectorTest.php new file mode 100644 index 0000000000..5fd518ae82 --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/LibreSignConfigurationCollectorTest.php @@ -0,0 +1,130 @@ +appConfig = self::getMockAppConfigWithReset(); + } + + public function tearDown(): void { + if ($this->pkiPath !== '') { + array_map('unlink', glob($this->pkiPath . '/*') ?: []); + rmdir($this->pkiPath); + } + } + + public function testAHistoricalReportKnowsNoConfigurationValue(): void { + $values = $this->collector()->collect($this->period(), ReportMode::HISTORICAL); + + foreach ($values as $value) { + $this->assertFalse($value->isAvailable()); + } + } + + public function testDefaultsMatchWhatLibreSignRuns(): void { + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame('16.0.0', $values['libresign.version']->value()); + $this->assertSame('JSignPdf', $values['libresign.signature_engine']->value()); + $this->assertSame('sync', $values['libresign.signing_mode']->value()); + $this->assertSame('openssl', $values['certificate.engine']->value()); + $this->assertFalse($values['certificate.root_ca_configured']->value()); + } + + #[DataProvider('storedValuesProvider')] + public function testStoredValuesAreNormalized(string $key, string $stored, string $metricId, string $expected): void { + $this->appConfig->setValueString('libresign', $key, $stored); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($expected, $values[$metricId]->value()); + } + + public static function storedValuesProvider(): array { + return [ + 'native signature engine' => ['signature_engine', 'PhpNative', 'libresign.signature_engine', 'PhpNative'], + 'unknown signature engine runs JSignPdf' => ['signature_engine', 'Other', 'libresign.signature_engine', 'JSignPdf'], + 'async signing' => ['signing_mode', 'async', 'libresign.signing_mode', 'async'], + 'unknown signing mode runs sync' => ['signing_mode', 'later', 'libresign.signing_mode', 'sync'], + 'cfssl' => ['certificate_engine', 'cfssl', 'certificate.engine', 'cfssl'], + 'no certificate engine' => ['certificate_engine', 'none', 'certificate.engine', 'none'], + 'unknown certificate engine' => ['certificate_engine', 'custom', 'certificate.engine', 'other'], + ]; + } + + #[DataProvider('rootCaProvider')] + public function testTheRootCaIsConfiguredWhenItsFilesExist(string $engine, array $files, bool $expected): void { + $this->pkiPath = sys_get_temp_dir() . '/libresign-usage-pki-' . uniqid('', true); + mkdir($this->pkiPath); + foreach ($files as $file) { + touch($this->pkiPath . '/' . $file); + } + $this->appConfig->setValueString('libresign', 'certificate_engine', $engine); + $this->appConfig->setValueString('libresign', 'config_path', $this->pkiPath); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($expected, $values['certificate.root_ca_configured']->value()); + } + + public static function rootCaProvider(): array { + return [ + 'openssl with certificate and key' => ['openssl', ['ca.pem', 'ca-key.pem'], true], + 'cfssl with certificate and key' => ['cfssl', ['ca.pem', 'ca-key.pem'], true], + 'certificate without key' => ['openssl', ['ca.pem'], false], + 'no certificate engine' => ['none', ['ca.pem', 'ca-key.pem'], false], + ]; + } + + public function testAMissingPkiDirectoryIsNotCreated(): void { + $missingPath = sys_get_temp_dir() . '/libresign-usage-missing-' . uniqid('', true); + $this->appConfig->setValueString('libresign', 'config_path', $missingPath); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertFalse($values['certificate.root_ca_configured']->value()); + $this->assertDirectoryDoesNotExist($missingPath); + } + + public function testNoConfigurationDetailBecomesAMetricValue(): void { + $this->pkiPath = sys_get_temp_dir() . '/libresign-usage-pki-' . uniqid('', true); + mkdir($this->pkiPath); + touch($this->pkiPath . '/ca.pem'); + touch($this->pkiPath . '/ca-key.pem'); + $this->appConfig->setValueString('libresign', 'config_path', $this->pkiPath); + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + foreach ($values as $value) { + $this->assertStringNotContainsString($this->pkiPath, (string)$value->value()); + } + } + + private function collector(): LibreSignConfigurationCollector { + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getAppVersion')->with('libresign')->willReturn('16.0.0'); + return new LibreSignConfigurationCollector($appManager, $this->appConfig); + } + + private function period(): ReportPeriod { + return ReportPeriod::monthContaining(new \DateTimeImmutable('2026-09-15T00:00:00Z')); + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/SigningActivityCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/SigningActivityCollectorTest.php new file mode 100644 index 0000000000..4c3c57e38f --- /dev/null +++ b/tests/php/Unit/Service/UsageStatistics/Collector/SigningActivityCollectorTest.php @@ -0,0 +1,44 @@ +createMock(UsageActivityReader::class); + $reader->method('countSigningRequestsCreated')->with($period)->willReturn(5); + $reader->method('countSigningRequestsCompleted')->with($period)->willReturn(4); + $reader->method('countSigningRequestsRejected')->with($period)->willReturn(0); + $reader->method('countObserversAdded')->with($period)->willReturn(2); + + $values = (new SigningActivityCollector($reader))->collect($period, $mode); + + $this->assertSame([ + 'signatures.requests_created' => 5, + 'signatures.completed' => 4, + 'signatures.rejected' => 0, + 'participants.observers_added' => 2, + ], array_map(static fn ($value) => $value->value(), $values)); + } + + public static function modesProvider(): array { + return [ + 'current' => [ReportMode::CURRENT], + 'historical' => [ReportMode::HISTORICAL], + ]; + } +} diff --git a/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php b/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php index 2e8a767433..ed63c6ef60 100644 --- a/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php +++ b/tests/php/Unit/Service/UsageStatistics/UsageReportBuilderTest.php @@ -14,6 +14,7 @@ use OCA\Libresign\Service\UsageStatistics\Model\ReportPeriod; use OCA\Libresign\Service\UsageStatistics\Source\UsageActivityReader; use OCA\Libresign\Service\UsageStatistics\UsageReportBuilder; +use OCA\Libresign\Service\UsageStatistics\UsageStatisticsCollectors; use OCA\Libresign\Service\UsageStatistics\UsageStatisticsException; use OCA\Libresign\Service\UsageStatistics\UsageStatisticsSchema; use PHPUnit\Framework\Attributes\DataProvider; @@ -28,6 +29,26 @@ public function setUp(): void { $this->activityReader = $this->createMock(UsageActivityReader::class); } + public function testEveryShippedSchemaMetricHasExactlyOneCollector(): void { + $owners = []; + foreach (UsageStatisticsCollectors::ALL as $collectorClass) { + $collector = (new \ReflectionClass($collectorClass))->newInstanceWithoutConstructor(); + foreach ($collector->metricIds() as $metricId) { + $this->assertArrayNotHasKey($metricId, $owners, $metricId . ' has more than one collector'); + $owners[$metricId] = $collectorClass; + } + } + + $schemaIds = array_map( + static fn ($definition): string => $definition->id(), + UsageStatisticsSchema::load()->definitions(), + ); + sort($schemaIds); + $collectedIds = array_keys($owners); + sort($collectedIds); + $this->assertSame($schemaIds, $collectedIds); + } + public function testBuildsTheMetricsInSchemaOrder(): void { $builder = $this->builder($this->schema(), [ $this->collector(['documents.files_created' => MetricValue::of(3), 'php.version' => MetricValue::of('8.3')]), From ef248f3ae42073fcbcd5fa33d66954763c73d5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Mon, 5 Oct 2026 22:09:08 -0300 Subject: [PATCH 4/5] test(usage-statistics): drop the redundant immutability check of the report period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DateTimeImmutable is immutable by definition, and discarding the result of modify() triggers a warning on PHP 8.5, which fails the unit suite there. Part of #8932 Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: André Maia --- .../Service/UsageStatistics/Model/ReportPeriodTest.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php b/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php index 5b60a19d48..41ff91df66 100644 --- a/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php +++ b/tests/php/Unit/Service/UsageStatistics/Model/ReportPeriodTest.php @@ -46,12 +46,4 @@ public function testNextReturnsTheFollowingMonth(): void { $this->assertSame('2027-01-01T00:00:00+00:00', $period->start()->format(\DateTimeInterface::RFC3339)); $this->assertSame('2027-02-01T00:00:00+00:00', $period->end()->format(\DateTimeInterface::RFC3339)); } - - public function testBoundariesAreImmutable(): void { - $period = ReportPeriod::monthContaining(new \DateTimeImmutable('2026-08-10T00:00:00Z')); - - $period->start()->modify('+1 day'); - - $this->assertSame('2026-08-01T00:00:00+00:00', $period->start()->format(\DateTimeInterface::RFC3339)); - } } From 371ff7489a6cfec2a1b512be21a218493e70f7dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Tue, 6 Oct 2026 21:54:11 -0300 Subject: [PATCH 5/5] fix(usage-statistics): count active users past the server batch limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nextcloud returns at most 100 users per getLastLoggedInUsers() call, so asking for 500 ended the loop after the first call and capped nextcloud.users_active_30d at 100. Request batches of 100, advance by what each call returned and stop on an empty page, so a lower server cap cannot end the count early either. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: André Maia --- .../Collector/EnvironmentCollector.php | 14 ++++---- .../Collector/EnvironmentCollectorTest.php | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php b/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php index 7525cd8aff..e2714ed82e 100644 --- a/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php +++ b/lib/Service/UsageStatistics/Collector/EnvironmentCollector.php @@ -25,7 +25,8 @@ */ class EnvironmentCollector implements IMetricCollector { private const ACTIVE_USERS_DAYS = 30; - private const LAST_LOGIN_BATCH_SIZE = 500; + // Nextcloud returns at most 100 users per call, whatever the limit asked. + private const LAST_LOGIN_BATCH_SIZE = 100; private const BACKGROUND_JOB_MODES = ['ajax', 'webcron', 'cron']; public function __construct( @@ -75,14 +76,15 @@ public function collect(ReportPeriod $period, ReportMode $mode): array { /** * Users come sorted by their last login, most recent first, so counting - * stops at the first one outside the window. + * stops at the first one outside the window. Pages advance by what each + * call returned and end on an empty one, so a server that caps the batch + * below the requested size cannot end the count early. */ private function countRecentlyActiveUsers(): int { $since = $this->timeFactory->getTime() - self::ACTIVE_USERS_DAYS * 86400; $count = 0; $offset = 0; - do { - $userIds = $this->userManager->getLastLoggedInUsers(self::LAST_LOGIN_BATCH_SIZE, $offset); + while (($userIds = $this->userManager->getLastLoggedInUsers(self::LAST_LOGIN_BATCH_SIZE, $offset)) !== []) { foreach ($userIds as $userId) { $user = $this->userManager->get($userId); if ($user === null) { @@ -93,8 +95,8 @@ private function countRecentlyActiveUsers(): int { } $count++; } - $offset += self::LAST_LOGIN_BATCH_SIZE; - } while (count($userIds) === self::LAST_LOGIN_BATCH_SIZE); + $offset += count($userIds); + } return $count; } diff --git a/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php b/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php index 4a5b0d8488..bce5af60bb 100644 --- a/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php +++ b/tests/php/Unit/Service/UsageStatistics/Collector/EnvironmentCollectorTest.php @@ -138,6 +138,42 @@ public function testActiveUsersStopAtTheFirstLoginOutsideTheWindow(): void { $this->assertSame(2, $values['nextcloud.users_active_30d']->value()); } + #[DataProvider('activeUsersBeyondOneBatchProvider')] + public function testActiveUsersAreCountedBeyondTheServerBatchLimit(int $active, int $inactive): void { + $lastLogins = []; + for ($i = 0; $i < $active; $i++) { + $lastLogins['active' . $i] = self::NOW - $i; + } + for ($i = 0; $i < $inactive; $i++) { + $lastLogins['inactive' . $i] = self::NOW - 31 * 86400 - $i; + } + $userManager = $this->createMock(IUserManager::class); + $userManager->method('countUsersTotal')->willReturn($active + $inactive); + // Same contract as Nextcloud's user manager: most recent login first, + // and never more than 100 users per call, whatever the limit asked. + $userManager->method('getLastLoggedInUsers')->willReturnCallback( + static fn (?int $limit = null, int $offset = 0): array => array_slice(array_keys($lastLogins), $offset, min(100, $limit ?: 25)), + ); + $userManager->method('get')->willReturnCallback(function (string $userId) use ($lastLogins): IUser { + $user = $this->createMock(IUser::class); + $user->method('getLastLogin')->willReturn($lastLogins[$userId]); + return $user; + }); + $this->userManager = $userManager; + + $values = $this->collector()->collect($this->period(), ReportMode::CURRENT); + + $this->assertSame($active, $values['nextcloud.users_active_30d']->value()); + } + + public static function activeUsersBeyondOneBatchProvider(): array { + return [ + 'all active, ending on a partial batch' => [250, 0], + 'all active, ending on a full batch' => [200, 0], + 'window ends in the second batch' => [150, 30], + ]; + } + public function testAnUnknownArchitectureIsReportedAsOther(): void { $runtime = $this->createMock(RuntimeEnvironment::class); $runtime->method('nextcloudVersion')->willReturn('36.0.1');