Skip to content

feat(usage-statistics): define schema v1 and collect its metrics - #9038

Open
maia-andre wants to merge 5 commits into
LibreSign:mainfrom
maia-andre:feat/usage-statistics-schema-8932
Open

maia-andre wants to merge 5 commits into
LibreSign:mainfrom
maia-andre:feat/usage-statistics-schema-8932

Conversation

@maia-andre

Copy link
Copy Markdown
Contributor

Resolves: #8932

📝 Summary

This adds LibreSign usage statistics schema v1 and the backend that builds a monthly report from it. Opt-in, storage and submission stay in #8933/#8934. This PR has no dependency on usage_statistics_client.

Composition (2384 added lines, 3 commits; review in commit order):

Commit Production Tests Schema/docs
feat(usage-statistics): add the LibreSign usage statistics schema v1 – – 95
feat(usage-statistics): build usage reports that fail safely 694 573 –
feat(usage-statistics): collect the schema v1 metrics 445 577 –

Start with appinfo/usage-statistics/README.md, then UsageReportBuilder.

Schema

  • appinfo/usage-statistics/schema-v1.json defines the 22 metrics of the issue, in the exact format the server registers.
  • The server refuses any extra metric field, so the source of truth and the reconstruction rule of each metric are in appinfo/usage-statistics/README.md, next to the JSON.
  • Snapshots are required: false, because historical reports cannot know past environment values.
  • The six monthly counters that can be rebuilt for every month are required: true.
  • The two certificate counters are required: false (see below).
  • UsageStatisticsSchema runs the server's validation rules plus the fixed v1 rules from the issue, so a schema the server would refuse fails CI here first.

Report builder

Every collector answers each of its metrics with either a value (0 is a known zero) or MetricValue::unavailable(). The whole report fails (UsageStatisticsException) instead of leaving a metric out when:

  • a schema metric has no collector, or more than one;
  • an answer is missing, extra, of the wrong type, negative, empty, longer than 1024 bytes or not UTF-8;
  • a required metric is unavailable;
  • a current snapshot is claimed for a historical period.

Counting rules (agreed in #8932)

  • Signing metrics count only requests on top-level files. An envelope and each of its files carry a request for the same signer, so this gives 5/5 for the issue's example.
  • Files inside an envelope are not in files_created. An envelope counts once in envelopes_created, including one stored before node_type existed: a top-level file that has children.
  • Identification documents create files and sign requests, but they are not signing requests, so they are left out of every metric.
  • Observers count only in participants.observers_added, not in requests_created.
  • Certificates count only leaf rows.

Historical availability

  • Rejection, observers and envelopes were introduced together with the columns that record them, so earlier months are a known 0.
  • libresign_crl exists since 12.1 for OpenSSL and since 12.2/13 for CFSSL. The certificate counters are available for every month when the first recorded certificate is not later than the first LibreSign file (a fresh install, where the root CA comes first). Otherwise they are available only for months that start after the first recorded certificate.
  • Hard deletes (crl:cleanup, deleting a request or the signed file) make every rebuilt month a lower bound. This is documented in the README.

Snapshots

  • Values are coarse. Versions are reduced to major.minor (major.minor.patch for Nextcloud). The architecture is normalized to x86_64, aarch64 or other, and unknown settings are reported as other.
  • Nothing identifying is ever reported. php_uname() is called only with 'm', so no host name or kernel string can leak. No path, CA identifier, serial or URL becomes a metric value.
  • Settings are read as stored. A report never builds the certificate engine, which can create the PKI directory or contact CFSSL.

Notes for review

  • certificate.certificates_revoked currently counts every leaf revocation, including the automatic SUPERSEDED revocation of click-to-sign certificates. I asked in Define LibreSign usage statistics schema v1 and metric collectors #8932 whether those should be excluded; that would be a one-line change in UsageActivityReader, plus its test.
  • database.version: OCP has no getter for it, so RuntimeEnvironment asks the server (VERSION(), SHOW server_version, sqlite_version()). Oracle is reported as unavailable. A version string that cannot be parsed fails the report instead of being guessed.
  • nextcloud.users_active_30d walks getLastLoggedInUsers() (most recent first) and stops at the first user outside the window. It is cheap in a background job, but the Add installation opt-in, report persistence and report administration APIs #8934 preview will pay this cost during the request.
  • OCP\ServerVersion is readonly and cannot be doubled in tests, so it is read through RuntimeEnvironment.

🧪 How to test

Build both reports of a month in a Nextcloud shell:

$builder = \OCP\Server::get(\OCA\Libresign\Service\UsageStatistics\UsageReportBuilder::class);
$period = \OCA\Libresign\Service\UsageStatistics\Model\ReportPeriod::monthContaining(new \DateTimeImmutable('2026-09-15T00:00:00Z'));
$builder->build($period, \OCA\Libresign\Service\UsageStatistics\Model\ReportMode::CURRENT);
$builder->build($period, \OCA\Libresign\Service\UsageStatistics\Model\ReportMode::HISTORICAL);

In the devcontainer (MariaDB, OpenSSL):

  • the current report has all 22 metrics, e.g. nextcloud.version = 36.0.0, database.version = 10.6, certificate.root_ca_configured = true, with the counters at 0;
  • the historical report of the same month has only the 8 monthly counters.

Validation evidence

  • Focused regression/acceptance test: 87 unit tests, plus 8 integration tests against a real database. The integration tests cover month boundaries, an envelope with files, an envelope stored before node_type, an identification document, observers, a rejection in the next month, root/intermediate/leaf certificates, building through the DI container, and no setting changed by a report.
  • Relevant lint/type/static-analysis checks: composer cs:check is clean. psalm adds no issue; locally I only see the same environment MissingDependency (psr/clock) reported for existing files.
  • Broader suite required by the changed surface: I removed the identification-document exclusion and the top-level filter from the SQL by hand, and each time the integration test failed.
  • CI reviewed

⚙️ API / Back-end changes

  • New OCA\Libresign\Service\UsageStatistics service: schema reader, report builder, collectors. No controller, route, job or migration.
  • Unit and/or integration tests added or updated
  • Authorization and negative paths checked when applicable: no endpoint; the negative paths are the fail-safe cases above
  • Capabilities updated when applicable: not applicable
  • Documentation updated when applicable: appinfo/usage-statistics/README.md
  • API documentation regenerated with composer openapi when necessary: not applicable, the API is unchanged

✅ Checklist

  • I have read and followed the contribution guide.
  • The diff does not contain unrelated refactors or generated changes.
  • Commits follow Conventional Commits where applicable.
  • All commits include the required DCO sign-off.

🤖 AI (if applicable)

  • The content of this PR was partially or fully generated using AI

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 LibreSign#8932
Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: André Maia <andrefnkmm@gmail.com>
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 LibreSign#8932
Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: André Maia <andrefnkmm@gmail.com>
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 LibreSign#8932
Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: André Maia <andrefnkmm@gmail.com>
@maia-andre
maia-andre requested a review from a team as a code owner October 6, 2026 00:58
…report period

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 LibreSign#8932
Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: André Maia <andrefnkmm@gmail.com>
vitormattos

This comment was marked as off-topic.

*/
class EnvironmentCollector implements IMetricCollector {
private const ACTIVE_USERS_DAYS = 30;
private const LAST_LOGIN_BATCH_SIZE = 500;

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.

Nextcloud 36 caps IUserManager::getLastLoggedInUsers() at 100 results per call, even when a larger limit is requested. With this batch size set to 500, the first call returns at most 100 users and the loop exits because count($userIds) === 500 is false. As a result, nextcloud.users_active_30d is capped at 100 on larger installations.

Please paginate using Nextcloud's actual maximum batch size and add a regression test with more than 100 active users.

Suggested change
private const LAST_LOGIN_BATCH_SIZE = 500;
private const LAST_LOGIN_BATCH_SIZE = 100;

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.

@vitormattos vitormattos left a comment •

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.

This PR is quite large, which is partly expected because LibreSign needs application-specific collectors for each metric.

However, I am not sure all of the new infrastructure belongs here. We already have the generic usage_statistics_client, which provides concepts such as Report, Metric, ReportingPeriod, and Protocol v1 validation. Its integration into LibreSign is tracked in #8933.

The LibreSign-specific part should clearly stay here: how each metric is collected, its historical availability, and the rules needed to interpret LibreSign data. But I would like to avoid creating parallel report/metric/protocol abstractions that #8933 will later need to replace or adapt to the client library.

Could you clarify which of these classes are intentionally temporary until #8933, and which ones are expected to remain after the generic client is integrated? It may be worth reducing this PR to the LibreSign-specific collection layer where possible.

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 <andrefnkmm@gmail.com>
@maia-andre

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @vitormattos.

Active users. Fixed in 371ff74. I confirmed the cap in lib/private/User/Manager.php (min(100, $limit)). The loop now asks for 100, advances by what each call returned and stops on an empty page, so a lower cap can't end the count early either. The new test fakes the server contract (most recent first, at most 100 per call) with 250, 200 and 150+30 users; on the previous code all three returned 100.

Which classes stay after #8933. I checked against usage_statistics_client at 39d6152. Its integration.md leaves metric definitions and aggregation semantics to the host application, and it has no schema, collector or availability concept. So I'd split it like this:

Stays in LibreSign:

  • schema-v1.json, UsageStatisticsSchema and MetricDefinition: the registered schema and its metric definitions (kind, required).
  • IMetricCollector, MetricValue (known zero vs. unavailable) and ReportMode (current vs. historical): the LibreSign availability rules.
  • The five collectors, UsageActivityReader, RuntimeEnvironment and UsageStatisticsCollectors.
  • In UsageReportBuilder, the LibreSign rules: one collector per metric, a required metric can't be unavailable, no snapshot in a historical report.

Overlaps with the client:

  • CollectedMetric duplicates Metric.
  • UsageReport duplicates Report (minus installationId and protocolVersion).
  • The builder's protocol checks (string up to 1024 bytes, at least one metric) duplicate the Metric/Report constructors.
  • ReportPeriod overlaps ReportingPeriod::monthContaining(). It also has contains() and next(), which the historical walk needs and the client doesn't have.

Since nothing is sent before #8933, I'd remove the overlap now instead of marking it temporary:

  1. Drop CollectedMetric and UsageReport. The builder returns the resolved values per MetricDefinition, and Integrate the generic usage statistics client into LibreSign #8933 maps them to the client's Metric and Report.
  2. Drop the protocol checks from the builder. The client's constructors enforce them at the point where a report is actually built for submission.
  3. Keep ReportPeriod for the month walk. Integrate the generic usage statistics client into LibreSign #8933 converts it with new ReportingPeriod($period->start(), $period->end()).

That leaves this PR with the schema, the collectors and the LibreSign availability rules. Does that match what you had in mind?

README. I'll keep the schema-specific parts here (sources of truth, history, rebuild rules, known zero vs. unavailable). The broader part, what LibreSign collects and how to read it, fits the admin manual. Do you want that documentation PR now, or together with the opt-in from #8934, so it doesn't describe a feature admins can't see yet?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Define LibreSign usage statistics schema v1 and metric collectors

2 participants