2026-04-27-exam-sprint-report-logging.md 32 KB

Exam Sprint Report Logging Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add useful, safe business logs for OUTLOOK and ACHIEVEMENT exam-sprint report creation, generation, query, download, and cleanup flows.

Architecture: Keep logs at application-service and generation-pipeline boundaries. Application logs describe public operations and business availability decisions; pipeline logs describe generation stages and failures. Renderer, PDF, and storage internals stay unchanged unless a later failure proves a missing boundary.

Tech Stack: Java 17, Spring Boot 3.3.5, SLF4J, JUnit 5, AssertJ, Spring Boot OutputCaptureExtension, Maven multi-module project.


File Structure

  • Modify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java
    • Responsibility: stage-level logs for pending report lookup, processing transition, HTML rendering, PDF generation, storage upload, success, skip reasons, and generation failure.
  • Modify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
    • Responsibility: public operation logs for async submit, dispatch failure, sync submit, query, download, and cleanup summary.
  • Modify: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
    • Responsibility: log-capture tests proving generation success and failure logs include useful report identifiers and stage names.
  • Modify: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
    • Responsibility: log-capture tests proving dispatch failure, query, missing storage download, and cleanup logs are emitted without leaking dispatch exception messages.

Task 1: Add failing pipeline log tests

Files:

  • Modify: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

  • [ ] Step 1: Add log-capture imports

Add these imports after the existing JUnit import block:

import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
  • Step 2: Enable output capture for the test class

Change the class declaration from:

class ExamSprintReportGenerationWorkerTest {

to:

@ExtendWith(OutputCaptureExtension.class)
class ExamSprintReportGenerationWorkerTest {
  • Step 3: Add a successful generation log test

Insert this test after processMarksReportSuccessAfterUpload():

    @Test
    void processLogsSuccessfulGenerationStages(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-log-success",
                ExamSprintReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode(),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));
        TestStorage storage = new TestStorage();
        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);

        worker.process("report-log-success");

        assertThat(output.getOut())
                .contains("exam_sprint_report_generation_started")
                .contains("reportId=report-log-success")
                .contains("reportType=OUTLOOK")
                .contains("stage=render_html")
                .contains("stage=pdf_generation")
                .contains("stage=storage_upload")
                .contains("exam_sprint_report_generation_succeeded")
                .contains("storageObjectKey=exam-sprint-outlook-report-report-log-success.pdf")
                .doesNotContain("<html><body>ok</body></html>");
    }
  • Step 4: Add a failed generation log test

Insert this test after processMarksReportFailedWhenGenerationPipelineThrows():

    @Test
    void processLogsFailedGenerationStage(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        repository.save(ExamSprintReport.pending(
                "report-log-failed",
                ExamSprintReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode(),
                FIXED_CLOCK.instant(),
                FIXED_CLOCK.instant().plusSeconds(86400)));

        ExamSprintReportGenerationWorker worker = createWorker(
                repository,
                List.of(new FailingRenderer()),
                new TestStorage());

        worker.process("report-log-failed");

        assertThat(output.getOut())
                .contains("exam_sprint_report_generation_failed")
                .contains("reportId=report-log-failed")
                .contains("reportType=OUTLOOK")
                .contains("stage=render_html")
                .contains("failureReason=renderer exploded")
                .contains("exceptionType=IllegalStateException");
    }
  • Step 5: Run the focused test and verify it fails

Run:

mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest test

Expected: FAIL because the application module does not yet emit exam_sprint_report_generation_started, stage=render_html, or exam_sprint_report_generation_failed logs.

Task 2: Implement generation pipeline logs

Files:

  • Modify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java

  • [ ] Step 1: Add logger and timing imports

Add these imports:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.TimeUnit;
  • Step 2: Add a logger field

Inside the class, above the existing fields, add:

    private static final Logger log = LoggerFactory.getLogger(ExamSprintReportGenerationPipeline.class);
  • Step 3: Replace generate with the logged implementation

Replace the whole generate(String reportId) method with:

    public Optional<ExamSprintReport> generate(String reportId) {
        long totalStartedNanos = System.nanoTime();
        Instant startedAt = clock.instant();
        ExamSprintReport report = repository.findById(reportId).orElse(null);
        if (report == null) {
            log.info("exam_sprint_report_generation_skipped reportId={} reason=not_found", reportId);
            return Optional.empty();
        }
        if (report.generationStatus() != ExamSprintReportGenerationStatus.PENDING) {
            log.info(
                    "exam_sprint_report_generation_skipped reportId={} reportType={} generationStatus={} reason=not_pending",
                    report.reportId(),
                    report.reportType(),
                    report.generationStatus());
            return Optional.empty();
        }
        if (report.isExpiredAt(startedAt)) {
            log.info(
                    "exam_sprint_report_generation_skipped reportId={} reportType={} generationStatus={} reason=expired expiresAt={}",
                    report.reportId(),
                    report.reportType(),
                    report.generationStatus(),
                    report.expiresAt());
            return Optional.empty();
        }

        log.info(
                "exam_sprint_report_generation_started reportId={} reportType={} generationStatus={} startedAt={}",
                report.reportId(),
                report.reportType(),
                report.generationStatus(),
                startedAt);
        ExamSprintReport processingReport = repository.save(report.processing(startedAt));
        log.info(
                "exam_sprint_report_generation_processing reportId={} reportType={} generationStatus={}",
                processingReport.reportId(),
                processingReport.reportType(),
                processingReport.generationStatus());

        String stage = "render_html";
        try {
            long stageStartedNanos = System.nanoTime();
            String html = rendererFor(processingReport).render(processingReport.payload(), startedAt);
            log.info(
                    "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} htmlLength={}",
                    processingReport.reportId(),
                    processingReport.reportType(),
                    stage,
                    elapsedMillis(stageStartedNanos),
                    html.length());

            stage = "pdf_generation";
            stageStartedNanos = System.nanoTime();
            byte[] pdfBytes = pdfGenerator.generate(html);
            log.info(
                    "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} pdfBytes={}",
                    processingReport.reportId(),
                    processingReport.reportType(),
                    stage,
                    elapsedMillis(stageStartedNanos),
                    pdfBytes.length);

            stage = "storage_upload";
            stageStartedNanos = System.nanoTime();
            ExamSprintReportStorage.StoredExamSprintReportFile storedFile = storage.upload(
                    processingReport.reportId(),
                    processingReport.reportType(),
                    fileNameOf(processingReport),
                    pdfBytes,
                    processingReport.expiresAt());
            log.info(
                    "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} storageObjectKey={} fileName={}",
                    processingReport.reportId(),
                    processingReport.reportType(),
                    stage,
                    elapsedMillis(stageStartedNanos),
                    storedFile.storageObjectKey(),
                    storedFile.fileName());

            stage = "success_save";
            ExamSprintReport successReport = repository.save(repository.findById(reportId)
                    .orElseThrow()
                    .success(clock.instant(), storedFile.storageObjectKey(), storedFile.fileName()));
            log.info(
                    "exam_sprint_report_generation_succeeded reportId={} reportType={} generationStatus={} durationMs={} storageObjectKey={} fileName={}",
                    successReport.reportId(),
                    successReport.reportType(),
                    successReport.generationStatus(),
                    elapsedMillis(totalStartedNanos),
                    successReport.storageObjectKey(),
                    successReport.fileName());
            return Optional.of(successReport);
        } catch (Exception exception) {
            String failureReason = failureReasonOf(exception);
            log.error(
                    "exam_sprint_report_generation_failed reportId={} reportType={} stage={} failureReason={} exceptionType={} durationMs={}",
                    processingReport.reportId(),
                    processingReport.reportType(),
                    stage,
                    failureReason,
                    exception.getClass().getSimpleName(),
                    elapsedMillis(totalStartedNanos),
                    exception);
            return repository.findById(reportId)
                    .map(current -> repository.save(current.failed(clock.instant(), failureReason)));
        }
    }
  • Step 4: Add elapsed-time helper

Add this method below fileNameOf and above failureReasonOf:

    private long elapsedMillis(long startedNanos) {
        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
    }
  • Step 5: Run the focused test and verify it passes

Run:

mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest test

Expected: PASS. The output-capture tests should find generation start, stage completion, success, and failure logs.

Task 3: Add failing application-service log tests

Files:

  • Modify: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java

  • [ ] Step 1: Add log-capture imports

Add these imports after the existing JUnit imports:

import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
  • Step 2: Enable output capture for the test class

Change the class declaration from:

class ExamSprintReportApplicationServiceTest {

to:

@ExtendWith(OutputCaptureExtension.class)
class ExamSprintReportApplicationServiceTest {
  • Step 3: Extend the dispatch-failure test with log assertions

Change the method signature from:

    void createOutlookReportReturnsFailedStatusWhenDispatchFails() {

to:

    void createOutlookReportReturnsFailedStatusWhenDispatchFails(CapturedOutput output) {

At the end of that test, after the existing status and failure-reason assertions, add:

        assertThat(output.getOut())
                .contains("exam_sprint_report_submitted")
                .contains("reportType=OUTLOOK")
                .contains("mode=async")
                .contains("exam_sprint_report_dispatch_failed")
                .contains("failureReason=report_generation_dispatch_failed")
                .contains("exceptionType=IllegalStateException")
                .doesNotContain("task-executor-7 rejected")
                .doesNotContain("dispatcher unavailable");
  • Step 4: Extend the query-success test with log assertions

Change the method signature from:

    void getReportReturnsDownloadUrlForSuccessfulReport() {

to:

    void getReportReturnsDownloadUrlForSuccessfulReport(CapturedOutput output) {

At the end of that test, add:

        assertThat(output.getOut())
                .contains("exam_sprint_report_query_completed")
                .contains("reportId=report-success")
                .contains("reportType=ACHIEVEMENT")
                .contains("generationStatus=SUCCESS")
                .contains("downloadUrlIncluded=true");
  • Step 5: Add a download missing-storage log test

Insert this test after downloadReportRejectsExpiredReportBeforeCleanupRuns():

    @Test
    void downloadReportLogsMissingStorageContent(CapturedOutput output) {
        TestRepository repository = new TestRepository();
        TestStorage storage = new TestStorage();
        repository.save(ExamSprintReport.pending(
                "report-missing-content",
                ExamSprintReportType.OUTLOOK,
                OBJECT_MAPPER.createObjectNode(),
                FIXED_CLOCK.instant().minusSeconds(600),
                FIXED_CLOCK.instant().plusSeconds(3600)).success(
                FIXED_CLOCK.instant().minusSeconds(300),
                "exam-sprint-outlook-report-report-missing-content.pdf",
                "exam-sprint-outlook-report-report-missing-content.pdf"));
        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);

        assertThatThrownBy(() -> service.downloadReport("report-missing-content"))
                .isInstanceOf(BusinessException.class)
                .extracting(exception -> ((BusinessException) exception).getErrorCode())
                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);

        assertThat(output.getOut())
                .contains("exam_sprint_report_download_started")
                .contains("reportId=report-missing-content")
                .contains("reportType=OUTLOOK")
                .contains("exam_sprint_report_download_missing_storage_content")
                .contains("storageObjectKey=exam-sprint-outlook-report-report-missing-content.pdf");
    }
  • Step 6: Extend the cleanup failure test with log assertions

Change the method signature from:

    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails() {

to:

    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails(CapturedOutput output) {

At the end of that test, add:

        assertThat(output.getOut())
                .contains("exam_sprint_report_cleanup_item_failed")
                .contains("reportId=report-delete-fails")
                .contains("storageObjectKey=first.pdf")
                .contains("exceptionType=IllegalStateException")
                .contains("exam_sprint_report_cleanup_completed")
                .contains("scannedCount=2")
                .contains("storageClearedCount=1")
                .contains("markedExpiredCount=0")
                .contains("failedCount=1");
  • Step 7: Run the focused test and verify it fails

Run:

mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test

Expected: FAIL because DefaultExamSprintReportApplicationService does not yet emit exam_sprint_report_submitted, exam_sprint_report_query_completed, download missing-storage, or cleanup summary logs.

Task 4: Implement application-service logs

Files:

  • Modify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java

  • [ ] Step 1: Add logger, optional, and timing imports

Add these imports:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

Keep the existing java.util.Set and java.util.UUID imports.

  • Step 2: Add a logger field

Inside the class, above REPORT_GENERATION_DISPATCH_FAILED, add:

    private static final Logger log = LoggerFactory.getLogger(DefaultExamSprintReportApplicationService.class);
  • Step 3: Replace submitReportGeneration with the logged implementation

Replace the whole submitReportGeneration(ExamSprintReportType reportType, JsonNode payload) method with:

    private CreateExamSprintReportResponse submitReportGeneration(ExamSprintReportType reportType, JsonNode payload) {
        Instant now = clock.instant();
        ExamSprintReport report = ExamSprintReport.pending(
                UUID.randomUUID().toString(),
                reportType,
                payload,
                now,
                now.plus(properties.getRetention()));
        repository.save(report);
        log.info(
                "exam_sprint_report_submitted reportId={} reportType={} generationStatus={} expiresAt={} mode=async",
                report.reportId(),
                report.reportType(),
                report.generationStatus(),
                report.expiresAt());
        try {
            dispatcher.dispatch(report.reportId());
            log.info(
                    "exam_sprint_report_dispatched reportId={} reportType={} mode=async",
                    report.reportId(),
                    report.reportType());
        } catch (RuntimeException exception) {
            report = repository.save(report.failed(now, dispatchFailureReason(exception)));
            log.warn(
                    "exam_sprint_report_dispatch_failed reportId={} reportType={} failureReason={} exceptionType={}",
                    report.reportId(),
                    report.reportType(),
                    report.failureReason(),
                    exception.getClass().getSimpleName());
        }
        return new CreateExamSprintReportResponse(
                report.reportId(),
                report.reportType(),
                report.generationStatus(),
                report.createdAt(),
                report.expiresAt());
    }
  • Step 4: Replace submitReportGenerationSync with the logged implementation

Replace the whole submitReportGenerationSync(ExamSprintReportType reportType, JsonNode payload) method with:

    private CreateExamSprintReportWithUrlResponse submitReportGenerationSync(
            ExamSprintReportType reportType,
            JsonNode payload) {
        long startedNanos = System.nanoTime();
        Instant now = clock.instant();
        ExamSprintReport report = ExamSprintReport.pending(
                UUID.randomUUID().toString(),
                reportType,
                payload,
                now,
                now.plus(properties.getRetention()));
        repository.save(report);

        log.info(
                "exam_sprint_report_sync_generation_started reportId={} reportType={} generationStatus={} expiresAt={}",
                report.reportId(),
                report.reportType(),
                report.generationStatus(),
                report.expiresAt());
        Optional<ExamSprintReport> generatedReportOptional = pipeline.generate(report.reportId());
        if (generatedReportOptional.isEmpty()) {
            log.warn(
                    "exam_sprint_report_sync_generation_unavailable reportId={} reportType={} reason=pipeline_empty durationMs={}",
                    report.reportId(),
                    report.reportType(),
                    elapsedMillis(startedNanos));
            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
        }

        ExamSprintReport generatedReport = generatedReportOptional.get();
        if (generatedReport.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS
                || generatedReport.storageObjectKey() == null) {
            log.warn(
                    "exam_sprint_report_sync_generation_unavailable reportId={} reportType={} generationStatus={} storageObjectKeyPresent={} durationMs={}",
                    generatedReport.reportId(),
                    generatedReport.reportType(),
                    generatedReport.generationStatus(),
                    generatedReport.storageObjectKey() != null,
                    elapsedMillis(startedNanos));
            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
        }

        String downloadUrl = storage.generateDownloadUrl(
                generatedReport.storageObjectKey(),
                properties.getDownloadExpiry()).toString();
        log.info(
                "exam_sprint_report_sync_generation_succeeded reportId={} reportType={} generationStatus={} durationMs={} storageObjectKey={}",
                generatedReport.reportId(),
                generatedReport.reportType(),
                generatedReport.generationStatus(),
                elapsedMillis(startedNanos),
                generatedReport.storageObjectKey());
        return new CreateExamSprintReportWithUrlResponse(
                generatedReport.reportId(),
                generatedReport.reportType(),
                generatedReport.generationStatus(),
                generatedReport.createdAt(),
                generatedReport.updatedAt(),
                generatedReport.expiresAt(),
                downloadUrl);
    }
  • Step 5: Replace getReport with the logged implementation

Replace the whole getReport(String reportId) method with:

    @Override
    public ExamSprintReportDetailResponse getReport(String reportId) {
        Instant now = clock.instant();
        ExamSprintReport report = requireReport(reportId);
        if (report.isExpiredAt(now) && report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
            report = repository.save(report.expired(now));
            log.info(
                    "exam_sprint_report_marked_expired_on_query reportId={} reportType={} generationStatus={} expiresAt={}",
                    report.reportId(),
                    report.reportType(),
                    report.generationStatus(),
                    report.expiresAt());
        }

        String downloadUrl = null;
        if (report.generationStatus() == ExamSprintReportGenerationStatus.SUCCESS
                && !report.isExpiredAt(now)
                && report.storageObjectKey() != null) {
            downloadUrl = storage.generateDownloadUrl(report.storageObjectKey(), properties.getDownloadExpiry()).toString();
        }

        log.info(
                "exam_sprint_report_query_completed reportId={} reportType={} generationStatus={} downloadUrlIncluded={} storageObjectKeyPresent={}",
                report.reportId(),
                report.reportType(),
                report.generationStatus(),
                downloadUrl != null,
                report.storageObjectKey() != null);
        return new ExamSprintReportDetailResponse(
                report.reportId(),
                report.reportType(),
                report.generationStatus(),
                report.createdAt(),
                report.updatedAt(),
                report.expiresAt(),
                downloadUrl,
                report.failureReason());
    }
  • Step 6: Replace downloadReport with the logged implementation

Replace the whole downloadReport(String reportId) method with:

    @Override
    public ReportDownloadContent downloadReport(String reportId) {
        Instant now = clock.instant();
        ExamSprintReport report = requireReport(reportId);
        log.info(
                "exam_sprint_report_download_started reportId={} reportType={} generationStatus={}",
                report.reportId(),
                report.reportType(),
                report.generationStatus());
        if (report.isExpiredAt(now)) {
            if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
                repository.save(report.expired(now));
            }
            log.warn(
                    "exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=expired expiresAt={}",
                    report.reportId(),
                    report.reportType(),
                    report.generationStatus(),
                    report.expiresAt());
            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
        }
        if (report.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS) {
            log.warn(
                    "exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=not_success",
                    report.reportId(),
                    report.reportType(),
                    report.generationStatus());
            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
        }
        if (report.storageObjectKey() == null) {
            log.warn(
                    "exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=missing_storage_key",
                    report.reportId(),
                    report.reportType(),
                    report.generationStatus());
            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
        }
        return storage.download(report.storageObjectKey())
                .map(content -> {
                    log.info(
                            "exam_sprint_report_download_succeeded reportId={} reportType={} storageObjectKey={} fileName={} bytes={}",
                            report.reportId(),
                            report.reportType(),
                            report.storageObjectKey(),
                            content.fileName(),
                            content.bytes().length);
                    return new ReportDownloadContent(content.fileName(), content.bytes(), content.contentType());
                })
                .orElseThrow(() -> {
                    log.warn(
                            "exam_sprint_report_download_missing_storage_content reportId={} reportType={} storageObjectKey={}",
                            report.reportId(),
                            report.reportType(),
                            report.storageObjectKey());
                    return new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
                });
    }
  • Step 7: Replace cleanupExpiredReports with the logged implementation

Replace the whole cleanupExpiredReports() method with:

    public void cleanupExpiredReports() {
        Instant now = clock.instant();
        List<ExamSprintReport> expiredReports = repository.findExpiredAtOrBefore(now);
        int storageClearedCount = 0;
        int markedExpiredCount = 0;
        int failedCount = 0;
        for (ExamSprintReport report : expiredReports) {
            try {
                if (report.storageObjectKey() != null) {
                    storage.delete(report.storageObjectKey());
                    repository.save(report.expiredWithStorageCleared(now));
                    storageClearedCount++;
                } else if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
                    repository.save(report.expired(now));
                    markedExpiredCount++;
                }
            } catch (RuntimeException exception) {
                failedCount++;
                log.warn(
                        "exam_sprint_report_cleanup_item_failed reportId={} reportType={} generationStatus={} storageObjectKey={} exceptionType={}",
                        report.reportId(),
                        report.reportType(),
                        report.generationStatus(),
                        report.storageObjectKey(),
                        exception.getClass().getSimpleName());
            }
        }
        log.info(
                "exam_sprint_report_cleanup_completed scannedCount={} storageClearedCount={} markedExpiredCount={} failedCount={}",
                expiredReports.size(),
                storageClearedCount,
                markedExpiredCount,
                failedCount);
    }
  • Step 8: Add elapsed-time helper

Add this method below requireReport and above dispatchFailureReason:

    private long elapsedMillis(long startedNanos) {
        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
    }
  • Step 9: Run the focused test and verify it passes

Run:

mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test

Expected: PASS. The output-capture tests should find async submit, dispatch failure, query, missing storage download, and cleanup logs.

Task 5: Regression verification

Files:

  • Verify only; no additional source edits expected.

  • [ ] Step 1: Run the application report tests together

Run:

mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest,ExamSprintReportApplicationServiceTest test

Expected: PASS.

  • Step 2: Run the full application module tests

Run:

mvn -pl abilities/exam-sprint/application -am test

Expected: PASS.

  • Step 3: Inspect the diff for sensitive logging

Run:

git diff -- abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java docs/superpowers/specs/2026-04-27-exam-sprint-report-logging-design.md docs/superpowers/plans/2026-04-27-exam-sprint-report-logging.md

Expected: Diff contains reportId, reportType, generationStatus, stage, durationMs, storageObjectKey, and fileName logs. Diff does not log full payload JSON, rendered HTML, PDF bytes content, Azure connection strings, Azure account names, Azure account keys, or complete exception messages for async dispatch failure.

  • Step 4: Do not create a git commit unless explicitly requested

Because the current session has no explicit user request to commit, stop after verification and report the modified files plus test results. If the user later asks to commit, follow the repository git safety protocol before creating a commit.