Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions appinfo/usage-statistics/README.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think part of this README would fit better in LibreSign/documentation, published at docs.libresign.coop.

It makes sense to keep the schema-specific information here, such as the source of truth, historical availability, reconstruction rules, and the meaning of known zero and unavailable.

The broader explanation about what LibreSign collects and how these statistics should be interpreted would be easier to maintain in the official documentation.

Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!--
- SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
# 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.
28 changes: 28 additions & 0 deletions appinfo/usage-statistics/schema-v1.json
Original file line number Diff line number Diff line change
@@ -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}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Service\UsageStatistics\Collector;

use OCA\Libresign\Service\UsageStatistics\Contract\IMetricCollector;
use OCA\Libresign\Service\UsageStatistics\Model\MetricValue;
use OCA\Libresign\Service\UsageStatistics\Model\ReportMode;
use OCA\Libresign\Service\UsageStatistics\Model\ReportPeriod;
use OCA\Libresign\Service\UsageStatistics\Source\UsageActivityReader;

/**
* Certificates are recorded only since LibreSign 12.1 (OpenSSL) and 12.2/13
* (CFSSL); nothing issued before that can be counted. The counts are known
* for a period when recording already covered the first LibreSign activity
* (a fresh install creates its root CA first), or when the period starts
* after the first recorded certificate (an upgraded instance).
*/
class CertificateActivityCollector implements IMetricCollector {
public function __construct(
private UsageActivityReader $reader,
) {
}

#[\Override]
public function metricIds(): array {
return ['certificate.certificates_issued', 'certificate.certificates_revoked'];
}

#[\Override]
public function collect(ReportPeriod $period, ReportMode $mode): array {
if (!$this->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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Service\UsageStatistics\Collector;

use OCA\Libresign\Service\UsageStatistics\Contract\IMetricCollector;
use OCA\Libresign\Service\UsageStatistics\Model\MetricValue;
use OCA\Libresign\Service\UsageStatistics\Model\ReportMode;
use OCA\Libresign\Service\UsageStatistics\Model\ReportPeriod;
use OCA\Libresign\Service\UsageStatistics\Source\UsageActivityReader;

/**
* Files are counted from the first install. Envelopes did not exist before
* LibreSign 12.2/13, so an earlier period has a known zero envelopes.
*/
class DocumentActivityCollector implements IMetricCollector {
public function __construct(
private UsageActivityReader $reader,
) {
}

#[\Override]
public function metricIds(): array {
return ['documents.files_created', 'documents.envelopes_created'];
}

#[\Override]
public function collect(ReportPeriod $period, ReportMode $mode): array {
return [
'documents.files_created' => MetricValue::of($this->reader->countStandaloneFilesCreated($period)),
'documents.envelopes_created' => MetricValue::of($this->reader->countEnvelopesCreated($period)),
];
}
}
129 changes: 129 additions & 0 deletions lib/Service/UsageStatistics/Collector/EnvironmentCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Service\UsageStatistics\Collector;

use OCA\Libresign\Service\Install\InstallTarget;
use OCA\Libresign\Service\UsageStatistics\Contract\IMetricCollector;
use OCA\Libresign\Service\UsageStatistics\Model\MetricValue;
use OCA\Libresign\Service\UsageStatistics\Model\ReportMode;
use OCA\Libresign\Service\UsageStatistics\Model\ReportPeriod;
use OCA\Libresign\Service\UsageStatistics\Source\RuntimeEnvironment;
use OCA\Libresign\Service\UsageStatistics\UsageStatisticsException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IAppConfig;
use OCP\IUserManager;

/**
* Current state of the Nextcloud server and its runtime. There is no record
* of these values in the past, so historical reports omit all of them.
*/
class EnvironmentCollector implements IMetricCollector {
private const ACTIVE_USERS_DAYS = 30;
// 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(
private IUserManager $userManager,
private IAppConfig $appConfig,
private ITimeFactory $timeFactory,
private RuntimeEnvironment $runtime,
) {
}

#[\Override]
public function metricIds(): array {
return [
'nextcloud.version',
'nextcloud.users_total',
'nextcloud.users_active_30d',
'nextcloud.background_job_mode',
'php.version',
'database.type',
'database.version',
'system.os_family',
'system.architecture',
];
}

#[\Override]
public function collect(ReportPeriod $period, ReportMode $mode): array {
if ($mode === ReportMode::HISTORICAL) {
return array_fill_keys($this->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. 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;
while (($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 += count($userIds);
}
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';
}
}
}
Loading
Loading