From 090fc4c5a9263abb22f0e4762bb2731d1647a88b Mon Sep 17 00:00:00 2001 From: imran Date: Tue, 25 Aug 2026 17:30:58 +0500 Subject: [PATCH 1/3] fix(jans-fido2): measure user-adoption metrics against the right population Signed-off-by: imran --- .../service/metric/Fido2MetricsService.java | 75 ++++++++++-- .../metric/Fido2MetricsServiceTest.java | 109 ++++++++++++++++++ 2 files changed, 172 insertions(+), 12 deletions(-) diff --git a/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java b/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java index cd0a45a1436..4b7210261e9 100644 --- a/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java +++ b/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java @@ -360,41 +360,92 @@ public void cleanupOldData(int retentionDays) { // ========== ANALYTICS AND REPORTING ========== /** - * Get user adoption metrics + * Get user adoption metrics. + *

+ * "New" and "returning" are decided against every successful registration on record, not just the + * rows inside {@code [startTime, endTime]}: a user only counts as new the first time their + * registration succeeds, and as returning if they had already registered before the window began. + * {@code adoptionRate} is newUsers against the cumulative population of everyone who has ever + * registered as of {@code endTime} — a self-contained figure bounded by how long metrics entries + * are retained, not a rate against the full identity directory. */ public Map getUserAdoptionMetrics(LocalDateTime startTime, LocalDateTime endTime) { List entries = getMetricsEntries(startTime, endTime); - + Map metrics = new HashMap<>(); - - // Total unique users + + // Every user with any activity in this window, regardless of operation or outcome Set uniqueUsers = entries.stream() .map(Fido2MetricsEntry::getUserId) .filter(Objects::nonNull) .collect(Collectors.toSet()); metrics.put(Fido2MetricsConstants.TOTAL_UNIQUE_USERS, uniqueUsers.size()); - // New users (first registration) - Set newUsers = entries.stream() - .filter(e -> Fido2MetricsConstants.REGISTRATION.equals(e.getOperationType()) && Fido2MetricsConstants.SUCCESS.equals(e.getStatus())) + // Users already known to have registered successfully before this window began + Set priorAdopters = getUsersRegisteredBefore(startTime); + + // Registration successes recorded inside this window + Set registeredInWindow = entries.stream() + .filter(e -> Fido2MetricsConstants.REGISTRATION.equals(e.getOperationType()) + && Fido2MetricsConstants.SUCCESS.equals(e.getStatus())) .map(Fido2MetricsEntry::getUserId) .filter(Objects::nonNull) .collect(Collectors.toSet()); + + // New users: this is the first time their registration ever succeeded + Set newUsers = new HashSet<>(registeredInWindow); + newUsers.removeAll(priorAdopters); metrics.put(Fido2MetricsConstants.NEW_USERS, newUsers.size()); - // Returning users + // Returning users: active this window, and already an adopter before it began. + // Computed directly against priorAdopters rather than as uniqueUsers minus newUsers, so a user + // who registers a second passkey and signs in within the same window is still counted here. Set returningUsers = new HashSet<>(uniqueUsers); - returningUsers.removeAll(newUsers); + returningUsers.retainAll(priorAdopters); metrics.put(Fido2MetricsConstants.RETURNING_USERS, returningUsers.size()); - // Adoption rate - if (!uniqueUsers.isEmpty()) { - metrics.put(Fido2MetricsConstants.ADOPTION_RATE, (double) newUsers.size() / uniqueUsers.size()); + // Adoption rate: new users against the cumulative population of everyone who has ever + // registered as of endTime (priorAdopters and newUsers are disjoint by construction). + long cumulativeAdopters = priorAdopters.size() + newUsers.size(); + if (cumulativeAdopters > 0) { + metrics.put(Fido2MetricsConstants.ADOPTION_RATE, (double) newUsers.size() / cumulativeAdopters); + } else { + metrics.put(Fido2MetricsConstants.ADOPTION_RATE, null); } return metrics; } + /** + * Distinct users whose registration succeeded at any point before {@code beforeTime}, searched + * directly against the metrics store rather than derived from the {@code [startTime, endTime]} + * window. Bounded by the metrics retention policy: a user whose only prior registration entry has + * already been cleaned up by {@link #cleanupOldData} will not appear here, and will be reported as + * new again. + */ + private Set getUsersRegisteredBefore(LocalDateTime beforeTime) { + try { + // Strictly before beforeTime, so a registration timestamped exactly at the window's start + // is not counted both as a prior adopter and as part of this window. + Date exclusiveUpperBound = new Date(convertToDate(beforeTime).getTime() - 1); + + Filter filter = Filter.createANDFilter( + Filter.createEqualityFilter("jansFido2MetricsOperationType", Fido2MetricsConstants.REGISTRATION), + Filter.createEqualityFilter("jansFido2MetricsStatus", Fido2MetricsConstants.SUCCESS), + Filter.createLessOrEqualFilter(Fido2MetricsConstants.JANS_TIMESTAMP, exclusiveUpperBound) + ); + + return persistenceEntryManager.findEntries(METRICS_ENTRY_BASE_DN, Fido2MetricsEntry.class, filter) + .stream() + .map(Fido2MetricsEntry::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } catch (Exception e) { + log.error("Failed to retrieve prior registrations before {}: {}", beforeTime, e.getMessage(), e); + return Collections.emptySet(); + } + } + /** * Whether this entry records a ceremony a user was actually present for. *

diff --git a/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2MetricsServiceTest.java b/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2MetricsServiceTest.java index 6d93b184010..d19187cb5e5 100644 --- a/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2MetricsServiceTest.java +++ b/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2MetricsServiceTest.java @@ -33,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; @@ -373,6 +374,114 @@ void aggregation_countsDeviceTypesPerCompletedCeremony() { assertEquals(Map.of("PLATFORM", 1L), metrics.get(Fido2MetricsConstants.DEVICE_TYPES)); } + /** + * A user who already registered before the window and only signs in during it must be reported as + * returning, never as new — the prior definition only ever looked at rows inside the window, so an + * established user with no registration activity here fell out of both buckets. + */ + @Test + void getUserAdoptionMetrics_ifUserRegisteredBeforeWindowAndSignsInDuringIt_isReturningNotNew() { + Fido2MetricsEntry signIn = statusEntry(Fido2MetricsConstants.AUTHENTICATION, Fido2MetricsConstants.SUCCESS); + signIn.setUserId("user-1"); + stubAdoptionQueries(List.of(signIn), List.of("user-1")); + + Map metrics = adoption(); + + assertEquals(1, metrics.get(Fido2MetricsConstants.TOTAL_UNIQUE_USERS)); + assertEquals(0, metrics.get(Fido2MetricsConstants.NEW_USERS)); + assertEquals(1, metrics.get(Fido2MetricsConstants.RETURNING_USERS)); + } + + /** + * The previous formula measured adoption against uniqueUsers active in the window, which shrinks + * as new registrations taper off — so it reported near-zero adoption exactly when adoption + * finished. A user with no prior registration record is new the first time they register, + * regardless of what else happens in the window. + */ + @Test + void getUserAdoptionMetrics_ifUserHasNoPriorRegistration_firstSuccessIsNew() { + Fido2MetricsEntry registration = statusEntry(Fido2MetricsConstants.REGISTRATION, Fido2MetricsConstants.SUCCESS); + registration.setUserId("user-2"); + stubAdoptionQueries(List.of(registration), List.of()); + + Map metrics = adoption(); + + assertEquals(1, metrics.get(Fido2MetricsConstants.NEW_USERS)); + assertEquals(0, metrics.get(Fido2MetricsConstants.RETURNING_USERS)); + assertEquals(1.0, (Double) metrics.get(Fido2MetricsConstants.ADOPTION_RATE), 0.0001); + } + + /** + * Enrolling a second passkey must not make an already-adopted user look new again — the old + * "newUsers" filter only checked REGISTRATION + SUCCESS inside the window, with no check for a + * prior registration, so this changed meaning with the date picker. + */ + @Test + void getUserAdoptionMetrics_ifUserEnrolsSecondPasskey_isNotCountedAsNewAgain() { + Fido2MetricsEntry secondRegistration = statusEntry(Fido2MetricsConstants.REGISTRATION, Fido2MetricsConstants.SUCCESS); + secondRegistration.setUserId("user-3"); + stubAdoptionQueries(List.of(secondRegistration), List.of("user-3")); + + Map metrics = adoption(); + + assertEquals(0, metrics.get(Fido2MetricsConstants.NEW_USERS)); + assertEquals(1, metrics.get(Fido2MetricsConstants.RETURNING_USERS)); + } + + /** + * adoptionRate is newUsers against everyone who has ever registered as of the end of the window + * (prior adopters plus this window's new ones) rather than against uniqueUsers active in the + * window, so it keeps meaning "share of all adopters that are new" instead of falling toward zero + * as adoption succeeds. + */ + @Test + void getUserAdoptionMetrics_adoptionRateIsAgainstCumulativeAdoptersNotWindowActivity() { + Fido2MetricsEntry newUser = statusEntry(Fido2MetricsConstants.REGISTRATION, Fido2MetricsConstants.SUCCESS); + newUser.setUserId("user-new"); + stubAdoptionQueries(List.of(newUser), List.of("user-old-1", "user-old-2", "user-old-3")); + + Map metrics = adoption(); + + // 1 new user out of 4 cumulative adopters (3 prior + this 1 new) + assertEquals(0.25, (Double) metrics.get(Fido2MetricsConstants.ADOPTION_RATE), 0.0001); + } + + /** + * With nobody ever having registered, there is no population to measure adoption against — the + * rate must be left unknown rather than published as a misleading 0.0. + */ + @Test + void getUserAdoptionMetrics_ifNoOneHasEverRegistered_adoptionRateIsNull() { + stubAdoptionQueries(List.of(), List.of()); + + assertNull(adoption().get(Fido2MetricsConstants.ADOPTION_RATE)); + } + + private Map adoption() { + return fido2MetricsService.getUserAdoptionMetrics(LocalDateTime.now().minusDays(1), LocalDateTime.now()); + } + + /** + * getUserAdoptionMetrics issues two distinct queries against the same store: one for activity + * inside the window, and one for registrations that succeeded before it began. The window query + * carries no status filter, so that is what distinguishes the two for stubbing purposes. + */ + private void stubAdoptionQueries(List windowEntries, List priorAdopterUserIds) { + when(persistenceEntryManager.findEntries(any(String.class), eq(Fido2MetricsEntry.class), + argThat(filter -> filter == null || !filter.toString().contains("jansFido2MetricsStatus")))) + .thenReturn(windowEntries); + + List priorEntries = priorAdopterUserIds.stream().map(userId -> { + Fido2MetricsEntry entry = statusEntry(Fido2MetricsConstants.REGISTRATION, Fido2MetricsConstants.SUCCESS); + entry.setUserId(userId); + return entry; + }).collect(java.util.stream.Collectors.toList()); + + when(persistenceEntryManager.findEntries(any(String.class), eq(Fido2MetricsEntry.class), + argThat(filter -> filter != null && filter.toString().contains("jansFido2MetricsStatus")))) + .thenReturn(priorEntries); + } + private Map performance() { return fido2MetricsService.getPerformanceMetrics(LocalDateTime.now().minusDays(1), LocalDateTime.now()); } From 6eca472f1e7ad695ade8689bd2ba469ed7ac03f2 Mon Sep 17 00:00:00 2001 From: imran Date: Thu, 27 Aug 2026 13:47:18 +0500 Subject: [PATCH 2/3] fix(jans-fido2): cast adoption-rate operand to long before addition Signed-off-by: imran --- .../java/io/jans/fido2/service/metric/Fido2MetricsService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java b/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java index 4b7210261e9..27f1ce56e2a 100644 --- a/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java +++ b/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java @@ -406,7 +406,7 @@ public Map getUserAdoptionMetrics(LocalDateTime startTime, Local // Adoption rate: new users against the cumulative population of everyone who has ever // registered as of endTime (priorAdopters and newUsers are disjoint by construction). - long cumulativeAdopters = priorAdopters.size() + newUsers.size(); + long cumulativeAdopters = (long) priorAdopters.size() + newUsers.size(); if (cumulativeAdopters > 0) { metrics.put(Fido2MetricsConstants.ADOPTION_RATE, (double) newUsers.size() / cumulativeAdopters); } else { From 40772590f5ae917f7867580d2505ddb3f72aeb5d Mon Sep 17 00:00:00 2001 From: imran Date: Thu, 27 Aug 2026 14:39:51 +0500 Subject: [PATCH 3/3] fix(jans-fido2): use paged retrieval for prior-adopter lookups Signed-off-by: imran --- .../fido2/service/metric/Fido2MetricsService.java | 11 ++++++++++- .../fido2/service/metric/Fido2MetricsServiceTest.java | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java b/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java index 27f1ce56e2a..2c9dc912f67 100644 --- a/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java +++ b/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2MetricsService.java @@ -14,6 +14,7 @@ import io.jans.fido2.model.trust.AttestationTrustDiagnostic; import io.jans.as.common.service.common.ApplicationFactory; import io.jans.orm.PersistenceEntryManager; +import io.jans.orm.model.SearchScope; import io.jans.orm.search.filter.Filter; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -64,6 +65,9 @@ public class Fido2MetricsService { private static final String METRICS_ENTRY_BASE_DN = "ou=fido2-metrics,o=jans"; private static final String METRICS_AGGREGATION_BASE_DN = "ou=fido2-aggregations,o=jans"; + /** Page size for the paged prior-adopters lookup in {@link #getUsersRegisteredBefore}. */ + private static final int PRIOR_ADOPTERS_CHUNK_SIZE = 1000; + // ========== METRICS ENTRY OPERATIONS ========== /** @@ -435,7 +439,12 @@ private Set getUsersRegisteredBefore(LocalDateTime beforeTime) { Filter.createLessOrEqualFilter(Fido2MetricsConstants.JANS_TIMESTAMP, exclusiveUpperBound) ); - return persistenceEntryManager.findEntries(METRICS_ENTRY_BASE_DN, Fido2MetricsEntry.class, filter) + // Paged retrieval: the unpaged findEntries(filter) overload issues a single search that a + // persistence backend enforcing a result-size limit can reject outright, which would + // misclassify every in-window registration as new. Paging in PRIOR_ADOPTERS_CHUNK_SIZE + // batches keeps this working past that limit. + return persistenceEntryManager.findEntries(METRICS_ENTRY_BASE_DN, Fido2MetricsEntry.class, filter, + SearchScope.SUB, null, 0, 0, PRIOR_ADOPTERS_CHUNK_SIZE) .stream() .map(Fido2MetricsEntry::getUserId) .filter(Objects::nonNull) diff --git a/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2MetricsServiceTest.java b/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2MetricsServiceTest.java index d19187cb5e5..c069c472386 100644 --- a/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2MetricsServiceTest.java +++ b/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2MetricsServiceTest.java @@ -12,6 +12,7 @@ import io.jans.fido2.model.metric.Fido2MetricsData; import io.jans.fido2.model.metric.Fido2MetricsEntry; import io.jans.orm.PersistenceEntryManager; +import io.jans.orm.model.SearchScope; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; @@ -478,7 +480,8 @@ private void stubAdoptionQueries(List windowEntries, List filter != null && filter.toString().contains("jansFido2MetricsStatus")))) + argThat(filter -> filter != null && filter.toString().contains("jansFido2MetricsStatus")), + any(SearchScope.class), any(), anyInt(), anyInt(), anyInt())) .thenReturn(priorEntries); }