# 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:

```java
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:

```java
class ExamSprintReportGenerationWorkerTest {
```

to:

```java
@ExtendWith(OutputCaptureExtension.class)
class ExamSprintReportGenerationWorkerTest {
```

- [ ] **Step 3: Add a successful generation log test**

Insert this test after `processMarksReportSuccessAfterUpload()`:

```java
    @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()`:

```java
    @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:

```bash
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:

```java
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:

```java
    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:

```java
    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`:

```java
    private long elapsedMillis(long startedNanos) {
        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
    }
```

- [ ] **Step 5: Run the focused test and verify it passes**

Run:

```bash
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:

```java
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:

```java
class ExamSprintReportApplicationServiceTest {
```

to:

```java
@ExtendWith(OutputCaptureExtension.class)
class ExamSprintReportApplicationServiceTest {
```

- [ ] **Step 3: Extend the dispatch-failure test with log assertions**

Change the method signature from:

```java
    void createOutlookReportReturnsFailedStatusWhenDispatchFails() {
```

to:

```java
    void createOutlookReportReturnsFailedStatusWhenDispatchFails(CapturedOutput output) {
```

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

```java
        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:

```java
    void getReportReturnsDownloadUrlForSuccessfulReport() {
```

to:

```java
    void getReportReturnsDownloadUrlForSuccessfulReport(CapturedOutput output) {
```

At the end of that test, add:

```java
        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()`:

```java
    @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:

```java
    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails() {
```

to:

```java
    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails(CapturedOutput output) {
```

At the end of that test, add:

```java
        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:

```bash
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:

```java
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:

```java
    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:

```java
    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:

```java
    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:

```java
    @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:

```java
    @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:

```java
    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`:

```java
    private long elapsedMillis(long startedNanos) {
        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
    }
```

- [ ] **Step 9: Run the focused test and verify it passes**

Run:

```bash
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:

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

Expected: PASS.

- [ ] **Step 2: Run the full application module tests**

Run:

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

Expected: PASS.

- [ ] **Step 3: Inspect the diff for sensitive logging**

Run:

```bash
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.
