From 9025003f4648c38735bf7074c9b1300383b86241 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:40:25 +0900 Subject: [PATCH 01/12] docs(analytics): align KPI evidence denominator --- .../java/com/clearfolio/viewer/analytics/KpiSnapshotRecord.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotRecord.java b/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotRecord.java index 65e44d4a9..5a91a33ef 100644 --- a/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotRecord.java +++ b/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotRecord.java @@ -14,7 +14,7 @@ * @param succeededJobs succeeded jobs in the snapshot * @param failedJobs failed jobs in the snapshot * @param deadLetteredJobs dead-lettered jobs in the snapshot - * @param conversionSuccessRate succeeded jobs divided by total jobs + * @param conversionSuccessRate succeeded jobs divided by terminal succeeded/failed jobs * @param p95TimeToPreviewMs p95 time to preview, when available */ public record KpiSnapshotRecord( From 377c7551a60b0d1e59fc10de1fd032c7bbc08508 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:40:36 +0900 Subject: [PATCH 02/12] docs(api): align KPI export denominator --- .../com/clearfolio/viewer/api/KpiSnapshotExportResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/clearfolio/viewer/api/KpiSnapshotExportResponse.java b/src/main/java/com/clearfolio/viewer/api/KpiSnapshotExportResponse.java index 82babf93a..b5c40a295 100644 --- a/src/main/java/com/clearfolio/viewer/api/KpiSnapshotExportResponse.java +++ b/src/main/java/com/clearfolio/viewer/api/KpiSnapshotExportResponse.java @@ -15,7 +15,7 @@ * @param succeededJobs succeeded jobs in the exported snapshot * @param failedJobs failed jobs in the exported snapshot * @param deadLetteredJobs dead-lettered jobs in the exported snapshot - * @param conversionSuccessRate succeeded jobs divided by total jobs + * @param conversionSuccessRate succeeded jobs divided by terminal succeeded/failed jobs * @param p95TimeToPreviewMs p95 time to preview, when available */ public record KpiSnapshotExportResponse( From 2c2abaeb27b25180e60f6b710f22dc6930e7ec30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:40:59 +0900 Subject: [PATCH 03/12] fix(analytics): use terminal conversion outcomes --- .../java/com/clearfolio/viewer/api/KpiSnapshotResponse.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/api/KpiSnapshotResponse.java b/src/main/java/com/clearfolio/viewer/api/KpiSnapshotResponse.java index a9a29b76a..fe0e4f5eb 100644 --- a/src/main/java/com/clearfolio/viewer/api/KpiSnapshotResponse.java +++ b/src/main/java/com/clearfolio/viewer/api/KpiSnapshotResponse.java @@ -17,7 +17,7 @@ * @param succeededJobs jobs with a ready preview artifact * @param failedJobs jobs in failed state * @param deadLetteredJobs failed jobs that exhausted retry handling - * @param conversionSuccessRate succeeded jobs divided by total jobs + * @param conversionSuccessRate succeeded jobs divided by terminal succeeded/failed jobs * @param p95TimeToPreviewMs p95 processing time for succeeded jobs with timestamps */ public record KpiSnapshotResponse( @@ -64,7 +64,8 @@ public static KpiSnapshotResponse from(List jobs) { } int total = jobs.size(); - double successRate = total == 0 ? 0.0 : (double) succeeded / total; + int terminal = succeeded + failed; + double successRate = terminal == 0 ? 0.0 : (double) succeeded / terminal; return new KpiSnapshotResponse( total, submitted, From da68456a01e618726886e7370ba169122b42a964 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:41:12 +0900 Subject: [PATCH 04/12] test(analytics): require terminal KPI denominator --- .../viewer/api/KpiSnapshotResponseTest.java | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/api/KpiSnapshotResponseTest.java b/src/test/java/com/clearfolio/viewer/api/KpiSnapshotResponseTest.java index 1e2dd385c..eba5afc20 100644 --- a/src/test/java/com/clearfolio/viewer/api/KpiSnapshotResponseTest.java +++ b/src/test/java/com/clearfolio/viewer/api/KpiSnapshotResponseTest.java @@ -29,11 +29,44 @@ void fromSkipsPreviewTimingWhenSucceededJobsDoNotHaveCompleteTimestamps() { assertNull(response.p95TimeToPreviewMs()); } + @Test + void conversionSuccessRateUsesOnlyTerminalOutcomes() { + KpiSnapshotResponse response = KpiSnapshotResponse.from(List.of( + job(ConversionJobStatus.SUCCEEDED), + job(ConversionJobStatus.SUCCEEDED), + job(ConversionJobStatus.FAILED), + job(ConversionJobStatus.SUBMITTED), + job(ConversionJobStatus.PROCESSING) + )); + + assertEquals(5, response.totalJobs()); + assertEquals(1, response.submittedJobs()); + assertEquals(1, response.processingJobs()); + assertEquals(2, response.succeededJobs()); + assertEquals(1, response.failedJobs()); + assertEquals(2.0 / 3.0, response.conversionSuccessRate(), 1.0e-12); + } + + @Test + void conversionSuccessRateIsZeroUntilOneJobReachesATerminalOutcome() { + KpiSnapshotResponse response = KpiSnapshotResponse.from(List.of( + job(ConversionJobStatus.SUBMITTED), + job(ConversionJobStatus.PROCESSING) + )); + + assertEquals(0.0, response.conversionSuccessRate()); + } + private ConversionJob succeededJob(Instant startedAt, Instant completedAt) { - ConversionJob job = mock(ConversionJob.class); - when(job.getStatus()).thenReturn(ConversionJobStatus.SUCCEEDED); + ConversionJob job = job(ConversionJobStatus.SUCCEEDED); when(job.getStartedAt()).thenReturn(startedAt); when(job.getCompletedAt()).thenReturn(completedAt); + return job; + } + + private ConversionJob job(ConversionJobStatus status) { + ConversionJob job = mock(ConversionJob.class); + when(job.getStatus()).thenReturn(status); when(job.isDeadLettered()).thenReturn(false); return job; } From 65cf2956ad69614cabbb1369b010b1278b06ab3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:41:38 +0900 Subject: [PATCH 05/12] test(analytics): align controller KPI expectation --- .../clearfolio/viewer/controller/AnalyticsControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/AnalyticsControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AnalyticsControllerTest.java index 7752589e0..da895f917 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AnalyticsControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AnalyticsControllerTest.java @@ -84,7 +84,7 @@ void kpiSnapshotSummarizesCurrentJobStates() { .jsonPath("$.succeededJobs").isEqualTo(1) .jsonPath("$.failedJobs").isEqualTo(1) .jsonPath("$.deadLetteredJobs").isEqualTo(1) - .jsonPath("$.conversionSuccessRate").value(value -> assertEquals(0.25, (Double) value)) + .jsonPath("$.conversionSuccessRate").value(value -> assertEquals(0.5, (Double) value)) .jsonPath("$.p95TimeToPreviewMs").value(value -> { assertNotNull(value); assertTrue(((Number) value).longValue() >= 0L); From 8428bee94b933a314da5743092ed0acb1fc58d85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:54:57 +0900 Subject: [PATCH 06/12] test(analytics): require KPI ledger metric migration --- .../analytics/KpiSnapshotLedgerTest.java | 91 +++++++++++++++++-- 1 file changed, 82 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java b/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java index d42349fde..dcf520080 100644 --- a/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java +++ b/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java @@ -43,20 +43,52 @@ void recordsSnapshotsInMemoryAndFiltersByTenant() { } @Test - void persistsAndReloadsSnapshotsWhenPathIsConfigured() { + void persistsAndReloadsSnapshotsWhenPathIsConfigured() throws Exception { Path ledgerPath = tempDir.resolve("kpi-snapshots.log"); KpiSnapshotLedger ledger = new KpiSnapshotLedger(ledgerPath, CLOCK); ledger.recordSnapshot(context("tenant-a"), snapshot(2, 1, 123L)); ledger.recordSnapshot(context("tenant-a"), snapshot(0, 0, null)); + String[] firstFields = Files.readAllLines(ledgerPath, StandardCharsets.UTF_8) + .getFirst() + .split("\t", -1); + assertEquals("SNAPSHOT", firstFields[0]); + assertEquals("terminal-outcomes-v1", firstFields[1]); + KpiSnapshotLedger reloaded = new KpiSnapshotLedger(ledgerPath, CLOCK); assertEquals(2, reloaded.snapshotsFor("tenant-a").size()); assertEquals(NOW, reloaded.snapshotsFor("tenant-a").getFirst().exportedAt()); + assertEquals(0.5, reloaded.snapshotsFor("tenant-a").getFirst().conversionSuccessRate()); assertEquals(123L, reloaded.snapshotsFor("tenant-a").getFirst().p95TimeToPreviewMs()); assertNull(reloaded.snapshotsFor("tenant-a").get(1).p95TimeToPreviewMs()); } + @Test + void migratesLegacyTotalDenominatorToTerminalOutcomeSemantics() throws Exception { + Path ledgerPath = Files.writeString( + tempDir.resolve("legacy-kpi-snapshots.log"), + legacySnapshotLine(4, 1, 1, 1, 1, 0, 0.25, 123L) + System.lineSeparator(), + StandardCharsets.UTF_8 + ); + + KpiSnapshotRecord migrated = new KpiSnapshotLedger(ledgerPath, CLOCK) + .snapshotsFor("tenant-a") + .getFirst(); + + assertEquals(4, migrated.totalJobs()); + assertEquals(1, migrated.succeededJobs()); + assertEquals(1, migrated.failedJobs()); + assertEquals(0.5, migrated.conversionSuccessRate()); + } + + @Test + void rejectsUnknownMetricVersionsAndInconsistentStoredRates() throws Exception { + assertInvalidLedger(currentSnapshotLine("unknown-v1", 4, 1, 1, 1, 1, 0, 0.5, 123L)); + assertInvalidLedger(currentSnapshotLine("terminal-outcomes-v1", 4, 1, 1, 1, 1, 0, 0.25, 123L)); + assertInvalidLedger(legacySnapshotLine(4, 1, 1, 1, 1, 0, 0.5, 123L)); + } + @Test void stringConstructorTreatsBlankPathAsInMemoryAndMissingPathAsEmpty() { KpiSnapshotLedger nullPath = new KpiSnapshotLedger((String) null); @@ -128,19 +160,60 @@ private static KpiSnapshotResponse snapshot(int totalJobs, int succeededJobs, Lo } private static String snapshotLine() { + return legacySnapshotLine(2, 1, 0, 1, 1, 0, 0.5, 123L); + } + + private static String legacySnapshotLine( + int totalJobs, + int submittedJobs, + int processingJobs, + int succeededJobs, + int failedJobs, + int deadLetteredJobs, + double conversionSuccessRate, + Long p95TimeToPreviewMs + ) { + return String.join("\t", + "SNAPSHOT", + encoded("tenant-a"), + encoded("subject-a"), + NOW.toString(), + String.valueOf(totalJobs), + String.valueOf(submittedJobs), + String.valueOf(processingJobs), + String.valueOf(succeededJobs), + String.valueOf(failedJobs), + String.valueOf(deadLetteredJobs), + String.valueOf(conversionSuccessRate), + p95TimeToPreviewMs == null ? "-" : String.valueOf(p95TimeToPreviewMs) + ); + } + + private static String currentSnapshotLine( + String metricVersion, + int totalJobs, + int submittedJobs, + int processingJobs, + int succeededJobs, + int failedJobs, + int deadLetteredJobs, + double conversionSuccessRate, + Long p95TimeToPreviewMs + ) { return String.join("\t", "SNAPSHOT", + metricVersion, encoded("tenant-a"), encoded("subject-a"), NOW.toString(), - "2", - "1", - "0", - "1", - "1", - "0", - "0.5", - "123" + String.valueOf(totalJobs), + String.valueOf(submittedJobs), + String.valueOf(processingJobs), + String.valueOf(succeededJobs), + String.valueOf(failedJobs), + String.valueOf(deadLetteredJobs), + String.valueOf(conversionSuccessRate), + p95TimeToPreviewMs == null ? "-" : String.valueOf(p95TimeToPreviewMs) ); } From e9c01b521ccde63516627cbbd72640d396c7c12b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:55:55 +0900 Subject: [PATCH 07/12] test(analytics): require migrated KPI export after restart --- .../controller/AnalyticsControllerTest.java | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/AnalyticsControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AnalyticsControllerTest.java index da895f917..552fbd511 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AnalyticsControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AnalyticsControllerTest.java @@ -4,11 +4,17 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Base64; import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.springframework.http.HttpHeaders; import org.springframework.test.web.reactive.server.WebTestClient; @@ -22,6 +28,9 @@ class AnalyticsControllerTest { + @TempDir + private Path tempDir; + private InMemoryConversionJobRepository repository; private KpiSnapshotLedger snapshotLedger; private WebTestClient webTestClient; @@ -30,9 +39,7 @@ class AnalyticsControllerTest { void setUp() { repository = new InMemoryConversionJobRepository(); snapshotLedger = new KpiSnapshotLedger(); - webTestClient = WebTestClient.bindToController( - new AnalyticsController(repository, new TenantAccessService(), snapshotLedger) - ).controllerAdvice(new ApiExceptionHandler()).build(); + webTestClient = client(snapshotLedger); } @Test @@ -148,6 +155,44 @@ void kpiSnapshotExportsReturnsTenantScopedEvidence() { .jsonPath("$[1]").doesNotExist(); } + @Test + void kpiSnapshotExportsMigratesLegacyRateAfterRestart() throws Exception { + Path ledgerPath = tempDir.resolve("legacy-kpi-snapshots.log"); + String legacyLine = String.join("\t", + "SNAPSHOT", + encoded(TenantContext.DEMO_TENANT_ID), + encoded(TenantContext.DEMO_SUBJECT_ID), + Instant.EPOCH.toString(), + "4", + "1", + "1", + "1", + "1", + "0", + "0.25", + "-" + ); + Files.writeString( + ledgerPath, + legacyLine + System.lineSeparator(), + StandardCharsets.UTF_8 + ); + + WebTestClient restartedClient = client(new KpiSnapshotLedger(ledgerPath.toString())); + + restartedClient.get() + .uri("/api/v1/analytics/kpi-snapshot-exports") + .headers(AnalyticsControllerTest::addAnalyticsAuth) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$[0].totalJobs").isEqualTo(4) + .jsonPath("$[0].succeededJobs").isEqualTo(1) + .jsonPath("$[0].failedJobs").isEqualTo(1) + .jsonPath("$[0].conversionSuccessRate").isEqualTo(0.5) + .jsonPath("$[1]").doesNotExist(); + } + @Test void kpiSnapshotRejectsMissingAnalyticsPermission() { webTestClient.get() @@ -172,6 +217,12 @@ void kpiSnapshotExportsRejectsMissingAnalyticsPermission() { .jsonPath("$.message").isEqualTo("missing permission: " + TenantPermissions.ANALYTICS_READ); } + private WebTestClient client(KpiSnapshotLedger ledger) { + return WebTestClient.bindToController( + new AnalyticsController(repository, new TenantAccessService(), ledger) + ).controllerAdvice(new ApiExceptionHandler()).build(); + } + private ConversionJob newJob(String fileName) { return new ConversionJob( UUID.randomUUID(), @@ -192,4 +243,10 @@ private static void addAuth(HttpHeaders headers, String permissions) { headers.add(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID); headers.add(TenantContext.PERMISSIONS_HEADER, permissions); } + + private static String encoded(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } } From f4a4b237bf067555320034b623073558d2d14951 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:57:23 +0900 Subject: [PATCH 08/12] fix(analytics): version and migrate KPI success-rate evidence --- .../viewer/analytics/KpiSnapshotLedger.java | 81 ++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java b/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java index f5cbde5d5..d45df0e3b 100644 --- a/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java +++ b/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java @@ -28,7 +28,10 @@ public class KpiSnapshotLedger { private static final String SNAPSHOT = "SNAPSHOT"; + private static final String TERMINAL_OUTCOMES_RATE_VERSION = "terminal-outcomes-v1"; private static final String NULL_FIELD = "-"; + private static final int LEGACY_FIELD_COUNT = 12; + private static final int CURRENT_FIELD_COUNT = 13; private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); @@ -76,7 +79,7 @@ public synchronized void recordSnapshot(TenantContext tenantContext, KpiSnapshot snapshot.succeededJobs(), snapshot.failedJobs(), snapshot.deadLetteredJobs(), - snapshot.conversionSuccessRate(), + terminalSuccessRate(snapshot.succeededJobs(), snapshot.failedJobs()), snapshot.p95TimeToPreviewMs() ); snapshots.add(record); @@ -110,24 +113,66 @@ private void load() { private void replayLine(String line) { String[] fields = line.split("\t", -1); - if (fields.length != 12 || !SNAPSHOT.equals(fields[0])) { - throw invalidLine(); + if (fields.length == LEGACY_FIELD_COUNT && SNAPSHOT.equals(fields[0])) { + replayLegacySnapshot(fields); + return; } + if (fields.length == CURRENT_FIELD_COUNT + && SNAPSHOT.equals(fields[0]) + && TERMINAL_OUTCOMES_RATE_VERSION.equals(fields[1])) { + replayCurrentSnapshot(fields); + return; + } + throw invalidLine(); + } + + private void replayLegacySnapshot(String[] fields) { + int totalJobs = integer(fields[4]); + int submittedJobs = integer(fields[5]); + int processingJobs = integer(fields[6]); + int succeededJobs = integer(fields[7]); + int failedJobs = integer(fields[8]); + int deadLetteredJobs = integer(fields[9]); + requireRate(rate(fields[10]), totalSuccessRate(totalJobs, succeededJobs)); snapshots.add(new KpiSnapshotRecord( requiredValue(fields[1]), requiredValue(fields[2]), instant(fields[3]), - integer(fields[4]), - integer(fields[5]), - integer(fields[6]), - integer(fields[7]), - integer(fields[8]), - integer(fields[9]), - rate(fields[10]), + totalJobs, + submittedJobs, + processingJobs, + succeededJobs, + failedJobs, + deadLetteredJobs, + terminalSuccessRate(succeededJobs, failedJobs), nullableLong(fields[11]) )); } + private void replayCurrentSnapshot(String[] fields) { + int totalJobs = integer(fields[5]); + int submittedJobs = integer(fields[6]); + int processingJobs = integer(fields[7]); + int succeededJobs = integer(fields[8]); + int failedJobs = integer(fields[9]); + int deadLetteredJobs = integer(fields[10]); + double currentRate = terminalSuccessRate(succeededJobs, failedJobs); + requireRate(rate(fields[11]), currentRate); + snapshots.add(new KpiSnapshotRecord( + requiredValue(fields[2]), + requiredValue(fields[3]), + instant(fields[4]), + totalJobs, + submittedJobs, + processingJobs, + succeededJobs, + failedJobs, + deadLetteredJobs, + currentRate, + nullableLong(fields[12]) + )); + } + private void appendLine(String line) { if (ledgerPath == null) { return; @@ -150,6 +195,7 @@ private void appendLine(String line) { private static String serialize(KpiSnapshotRecord record) { return String.join("\t", SNAPSHOT, + TERMINAL_OUTCOMES_RATE_VERSION, field(record.tenantId()), field(record.subjectId()), field(record.exportedAt()), @@ -164,6 +210,21 @@ private static String serialize(KpiSnapshotRecord record) { ); } + private static double totalSuccessRate(int totalJobs, int succeededJobs) { + return totalJobs == 0 ? 0.0 : (double) succeededJobs / totalJobs; + } + + private static double terminalSuccessRate(int succeededJobs, int failedJobs) { + int terminalJobs = succeededJobs + failedJobs; + return terminalJobs == 0 ? 0.0 : (double) succeededJobs / terminalJobs; + } + + private static void requireRate(double storedRate, double expectedRate) { + if (Double.compare(storedRate, expectedRate) != 0) { + throw invalidLine(); + } + } + private static String field(String value) { return ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8)); } From 35f5f1c5add29b41303dc39636ba6932e42a40a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:58:25 +0900 Subject: [PATCH 09/12] test(analytics): keep KPI fixtures internally consistent --- .../analytics/KpiSnapshotLedgerTest.java | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java b/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java index dcf520080..1c3a81394 100644 --- a/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java +++ b/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java @@ -33,8 +33,8 @@ class KpiSnapshotLedgerTest { void recordsSnapshotsInMemoryAndFiltersByTenant() { KpiSnapshotLedger ledger = new KpiSnapshotLedger(); - ledger.recordSnapshot(context("tenant-a"), snapshot(2, 1, 123L)); - ledger.recordSnapshot(context("tenant-b"), snapshot(1, 0, null)); + ledger.recordSnapshot(context("tenant-a"), snapshot(2, 1, 1, 123L)); + ledger.recordSnapshot(context("tenant-b"), snapshot(1, 0, 1, null)); assertEquals(1, ledger.snapshotsFor("tenant-a").size()); assertEquals(123L, ledger.snapshotsFor("tenant-a").getFirst().p95TimeToPreviewMs()); @@ -47,8 +47,8 @@ void persistsAndReloadsSnapshotsWhenPathIsConfigured() throws Exception { Path ledgerPath = tempDir.resolve("kpi-snapshots.log"); KpiSnapshotLedger ledger = new KpiSnapshotLedger(ledgerPath, CLOCK); - ledger.recordSnapshot(context("tenant-a"), snapshot(2, 1, 123L)); - ledger.recordSnapshot(context("tenant-a"), snapshot(0, 0, null)); + ledger.recordSnapshot(context("tenant-a"), snapshot(2, 1, 1, 123L)); + ledger.recordSnapshot(context("tenant-a"), snapshot(0, 0, 0, null)); String[] firstFields = Files.readAllLines(ledgerPath, StandardCharsets.UTF_8) .getFirst() @@ -129,7 +129,10 @@ void reportsLoadAndWriteFailures() throws Exception { KpiSnapshotLedger ledger = new KpiSnapshotLedger(ledgerPath, CLOCK); Files.writeString(blockedParent, "not a directory", StandardCharsets.UTF_8); - assertThrows(IllegalStateException.class, () -> ledger.recordSnapshot(context("tenant-a"), snapshot(1, 1, null))); + assertThrows( + IllegalStateException.class, + () -> ledger.recordSnapshot(context("tenant-a"), snapshot(1, 1, 0, null)) + ); } private void assertInvalidLedger(String line) throws Exception { @@ -146,15 +149,23 @@ private static TenantContext context(String tenantId) { return new TenantContext(tenantId, "subject-a", java.util.Set.of(TenantPermissions.ANALYTICS_READ)); } - private static KpiSnapshotResponse snapshot(int totalJobs, int succeededJobs, Long p95TimeToPreviewMs) { + private static KpiSnapshotResponse snapshot( + int totalJobs, + int succeededJobs, + int failedJobs, + Long p95TimeToPreviewMs + ) { + int submittedJobs = totalJobs - succeededJobs - failedJobs; + int terminalJobs = succeededJobs + failedJobs; + double successRate = terminalJobs == 0 ? 0.0 : (double) succeededJobs / terminalJobs; return new KpiSnapshotResponse( totalJobs, - 1, + submittedJobs, 0, succeededJobs, - 1, + failedJobs, 0, - 0.5, + successRate, p95TimeToPreviewMs ); } From e7cb543b936f0284e7c8b09142e8817896b0bfbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:35:00 +0900 Subject: [PATCH 10/12] test(analytics): cover KPI migration boundary branches --- .../analytics/KpiSnapshotLedgerTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java b/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java index 1c3a81394..442f26315 100644 --- a/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java +++ b/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java @@ -82,11 +82,39 @@ void migratesLegacyTotalDenominatorToTerminalOutcomeSemantics() throws Exception assertEquals(0.5, migrated.conversionSuccessRate()); } + @Test + void migratesEmptyLegacySnapshotToZeroTerminalRate() throws Exception { + Path ledgerPath = Files.writeString( + tempDir.resolve("empty-legacy-kpi-snapshot.log"), + legacySnapshotLine(0, 0, 0, 0, 0, 0, 0.0, null) + System.lineSeparator(), + StandardCharsets.UTF_8 + ); + + KpiSnapshotRecord migrated = new KpiSnapshotLedger(ledgerPath, CLOCK) + .snapshotsFor("tenant-a") + .getFirst(); + + assertEquals(0, migrated.totalJobs()); + assertEquals(0.0, migrated.conversionSuccessRate()); + assertNull(migrated.p95TimeToPreviewMs()); + } + @Test void rejectsUnknownMetricVersionsAndInconsistentStoredRates() throws Exception { assertInvalidLedger(currentSnapshotLine("unknown-v1", 4, 1, 1, 1, 1, 0, 0.5, 123L)); assertInvalidLedger(currentSnapshotLine("terminal-outcomes-v1", 4, 1, 1, 1, 1, 0, 0.25, 123L)); assertInvalidLedger(legacySnapshotLine(4, 1, 1, 1, 1, 0, 0.5, 123L)); + assertInvalidLedger(currentSnapshotLine( + "terminal-outcomes-v1", + 4, + 1, + 1, + 1, + 1, + 0, + 0.5, + 123L + ).replace("SNAPSHOT", "BROKEN")); } @Test From f4139dd6f3deed0ccd077d2527ddf88fe3cce8eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:40:31 +0900 Subject: [PATCH 11/12] test(analytics): require domain-valid durable KPI numerics --- .../analytics/KpiSnapshotLedgerTest.java | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java b/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java index 442f26315..a03347358 100644 --- a/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java +++ b/src/test/java/com/clearfolio/viewer/analytics/KpiSnapshotLedgerTest.java @@ -146,6 +146,37 @@ void rejectsInvalidPersistedLedgerLines() throws Exception { assertInvalidLedger(snapshotLine().replace(encoded("tenant-a"), "!")); } + @Test + void rejectsNonFiniteOutOfRangeAndNegativeLatencyEvidence() throws Exception { + String current = currentSnapshotLine( + "terminal-outcomes-v1", 2, 0, 0, 1, 1, 0, 0.5, 123L + ); + assertInvalidLedger(current.replace("\t0.5\t", "\tNaN\t")); + assertInvalidLedger(current.replace("\t0.5\t", "\tInfinity\t")); + assertInvalidLedger(current.replace("\t0.5\t", "\t-Infinity\t")); + assertInvalidLedger(current.replace("\t0.5\t", "\t-0.01\t")); + assertInvalidLedger(current.replace("\t0.5\t", "\t1.01\t")); + assertInvalidLedger(current.replace("\t123", "\t-1")); + } + + @Test + void acceptsInclusiveKpiNumericBoundaries() throws Exception { + assertValidLedger( + currentSnapshotLine( + "terminal-outcomes-v1", 1, 0, 0, 0, 1, 0, 0.0, 0L + ), + 0.0, + 0L + ); + assertValidLedger( + currentSnapshotLine( + "terminal-outcomes-v1", 1, 0, 0, 1, 0, 0, 1.0, 0L + ), + 1.0, + 0L + ); + } + @Test void reportsLoadAndWriteFailures() throws Exception { Path directory = tempDir.resolve("directory-ledger"); @@ -170,7 +201,25 @@ private void assertInvalidLedger(String line) throws Exception { StandardCharsets.UTF_8 ); - assertThrows(IllegalStateException.class, () -> new KpiSnapshotLedger(ledgerPath, CLOCK)); + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> new KpiSnapshotLedger(ledgerPath, CLOCK) + ); + assertEquals("kpi snapshot ledger contains an invalid line", error.getMessage()); + } + + private void assertValidLedger(String line, double expectedRate, Long expectedP95) throws Exception { + Path ledgerPath = Files.writeString( + tempDir.resolve(UUID.randomUUID() + ".log"), + line + System.lineSeparator(), + StandardCharsets.UTF_8 + ); + + KpiSnapshotRecord record = new KpiSnapshotLedger(ledgerPath, CLOCK) + .snapshotsFor("tenant-a") + .getFirst(); + assertEquals(expectedRate, record.conversionSuccessRate()); + assertEquals(expectedP95, record.p95TimeToPreviewMs()); } private static TenantContext context(String tenantId) { From 734f7d1b8c1349bd46acdc65fe61886222c8136a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:41:38 +0900 Subject: [PATCH 12/12] fix(analytics): reject invalid durable KPI numerics --- .../viewer/analytics/KpiSnapshotLedger.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java b/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java index d45df0e3b..bbe08d5dd 100644 --- a/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java +++ b/src/main/java/com/clearfolio/viewer/analytics/KpiSnapshotLedger.java @@ -274,7 +274,11 @@ private static int integer(String field) { private static double rate(String field) { try { - return Double.parseDouble(field); + double parsed = Double.parseDouble(field); + if (!Double.isFinite(parsed) || parsed < 0.0 || parsed > 1.0) { + throw invalidLine(); + } + return parsed; } catch (NumberFormatException ex) { throw invalidLine(ex); } @@ -285,7 +289,11 @@ private static Long nullableLong(String field) { return null; } try { - return Long.parseLong(field); + long parsed = Long.parseLong(field); + if (parsed < 0L) { + throw invalidLine(); + } + return parsed; } catch (NumberFormatException ex) { throw invalidLine(ex); }