ソースを参照

Merge branch 'chore/丰富报告日志' of jyx/dcjxb.microservice into master

金逸霄 2 週間 前
コミット
912351f183

+ 187 - 13
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java

@@ -17,16 +17,23 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import jakarta.validation.ConstraintViolation;
 import jakarta.validation.Validator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import java.time.Clock;
 import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.UUID;
+import java.util.concurrent.TimeUnit;
 
 @Service
 public class DefaultExamSprintReportApplicationService implements ExamSprintReportApplicationService {
 
+    private static final Logger log = LoggerFactory.getLogger(DefaultExamSprintReportApplicationService.class);
+
     private static final String REPORT_GENERATION_DISPATCH_FAILED = "report_generation_dispatch_failed";
 
     private final ExamSprintReportRepository repository;
@@ -90,10 +97,26 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
                 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(),
+                    exceptionType(exception));
         }
         return new CreateExamSprintReportResponse(
                 report.reportId(),
@@ -106,6 +129,7 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     private CreateExamSprintReportWithUrlResponse submitReportGenerationSync(
             ExamSprintReportType reportType,
             JsonNode payload) {
+        long startedNanos = System.nanoTime();
         Instant now = clock.instant();
         ExamSprintReport report = ExamSprintReport.pending(
                 UUID.randomUUID().toString(),
@@ -114,17 +138,58 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
                 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> generatedReportOption = pipeline.generate(report.reportId());
+        if (generatedReportOption.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 = pipeline.generate(report.reportId())
-                .orElseThrow(() -> new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE));
+        ExamSprintReport generatedReport = generatedReportOption.orElseThrow();
         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();
+        String downloadUrl;
+        try {
+            downloadUrl = storage.generateDownloadUrl(
+                    generatedReport.storageObjectKey(),
+                    properties.getDownloadExpiry()).toString();
+        } catch (RuntimeException exception) {
+            log.warn(
+                    "exam_sprint_report_sync_generation_unavailable reportId={} reportType={} generationStatus={} reason=download_url_generation_failed exceptionType={} durationMs={}",
+                    generatedReport.reportId(),
+                    generatedReport.reportType(),
+                    generatedReport.generationStatus(),
+                    exceptionType(exception),
+                    elapsedMillis(startedNanos));
+            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+        }
+        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(),
@@ -141,15 +206,40 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         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();
+            try {
+                downloadUrl = storage.generateDownloadUrl(report.storageObjectKey(), properties.getDownloadExpiry()).toString();
+            } catch (RuntimeException exception) {
+                log.warn(
+                        "exam_sprint_report_download_url_generation_failed reportId={} reportType={} generationStatus={} storageObjectKey={} exceptionType={}",
+                        report.reportId(),
+                        report.reportType(),
+                        report.generationStatus(),
+                        report.storageObjectKey(),
+                        exceptionType(exception));
+            }
         }
 
+        boolean downloadUrlIncluded = downloadUrl != null;
+        log.info(
+                "exam_sprint_report_query_completed reportId={} reportType={} generationStatus={} downloadUrlIncluded={} storageObjectKeyPresent={}",
+                report.reportId(),
+                report.reportType(),
+                report.generationStatus(),
+                downloadUrlIncluded,
+                report.storageObjectKey() != null);
+
         return new ExamSprintReportDetailResponse(
                 report.reportId(),
                 report.reportType(),
@@ -165,34 +255,110 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     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));
+                report = repository.save(report.expired(now));
             }
+            log.warn(
+                    "exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=expired",
+                    report.reportId(),
+                    report.reportType(),
+                    report.generationStatus());
             throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
         }
-        if (report.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS || report.storageObjectKey() == null) {
+        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);
         }
-        return storage.download(report.storageObjectKey())
-                .map(content -> new ReportDownloadContent(content.fileName(), content.bytes(), content.contentType()))
-                .orElseThrow(() -> 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);
+        }
+
+        Optional<ExamSprintReportStorage.StoredExamSprintReportContent> content;
+        try {
+            content = storage.download(report.storageObjectKey());
+        } catch (RuntimeException exception) {
+            log.warn(
+                    "exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=storage_download_failed storageObjectKey={} exceptionType={}",
+                    report.reportId(),
+                    report.reportType(),
+                    report.generationStatus(),
+                    report.storageObjectKey(),
+                    exceptionType(exception));
+            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+        }
+        if (content.isEmpty()) {
+            log.warn(
+                    "exam_sprint_report_download_missing_storage_content reportId={} reportType={} storageObjectKey={}",
+                    report.reportId(),
+                    report.reportType(),
+                    report.storageObjectKey());
+            throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+        }
+
+        ReportDownloadContent downloadContent = content.map(storedContent -> new ReportDownloadContent(
+                        storedContent.fileName(),
+                        storedContent.bytes(),
+                        storedContent.contentType()))
+                .orElseThrow();
+        log.info(
+                "exam_sprint_report_download_succeeded reportId={} reportType={} storageObjectKey={} fileName={} byteLength={}",
+                report.reportId(),
+                report.reportType(),
+                report.storageObjectKey(),
+                downloadContent.fileName(),
+                downloadContent.bytes().length);
+        return downloadContent;
     }
 
     public void cleanupExpiredReports() {
         Instant now = clock.instant();
-        for (ExamSprintReport report : repository.findExpiredAtOrBefore(now)) {
+        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 ignored) {
+            } 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(),
+                        exceptionType(exception));
                 // keep this expired report retriable on a later cleanup run
             }
         }
+        log.info(
+                "exam_sprint_report_cleanup_completed scannedCount={} storageClearedCount={} markedExpiredCount={} failedCount={}",
+                expiredReports.size(),
+                storageClearedCount,
+                markedExpiredCount,
+                failedCount);
     }
 
     private ExamSprintReport requireReport(String reportId) {
@@ -204,6 +370,14 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
         return REPORT_GENERATION_DISPATCH_FAILED;
     }
 
+    private String exceptionType(RuntimeException exception) {
+        return exception.getClass().getSimpleName();
+    }
+
+    private long elapsedMillis(long startedNanos) {
+        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
+    }
+
     private void validateOutlookPayload(JsonNode payload) {
         OutlookExamSprintReportPayload reportPayload = readPayload(payload, OutlookExamSprintReportPayload.class);
 

+ 111 - 8
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java

@@ -6,16 +6,21 @@ import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfG
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import java.time.Clock;
 import java.time.Instant;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 
 @Service
 public class ExamSprintReportGenerationPipeline {
 
+    private static final Logger log = LoggerFactory.getLogger(ExamSprintReportGenerationPipeline.class);
+
     private final ExamSprintReportRepository repository;
     private final List<ExamSprintReportRenderer> renderers;
     private final ExamSprintReportPdfGenerator pdfGenerator;
@@ -37,27 +42,117 @@ public class ExamSprintReportGenerationPipeline {
 
     public Optional<ExamSprintReport> generate(String reportId) {
         Instant startedAt = clock.instant();
+        long startedNanos = System.nanoTime();
         ExamSprintReport report = repository.findById(reportId).orElse(null);
-        if (report == null
-                || report.generationStatus() != ExamSprintReportGenerationStatus.PENDING
-                || report.isExpiredAt(startedAt)) {
+        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",
+                    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={}",
+                    reportId,
+                    report.reportType(),
+                    report.generationStatus(),
+                    report.expiresAt());
             return Optional.empty();
         }
 
+        log.info(
+                "exam_sprint_report_generation_started reportId={} reportType={} generationStatus={} startedAt={}",
+                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 = "renderer_selection";
         try {
-            String html = rendererFor(processingReport).render(processingReport.payload(), startedAt);
+            ExamSprintReportRenderer renderer = rendererFor(processingReport);
+
+            stage = "render_html";
+            long renderStartedNanos = System.nanoTime();
+            String html = renderer.render(processingReport.payload(), startedAt);
+            log.info(
+                    "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage=render_html durationMs={} htmlLength={}",
+                    processingReport.reportId(),
+                    processingReport.reportType(),
+                    elapsedMillis(renderStartedNanos),
+                    html.length());
+
+            stage = "pdf_generation";
+            long pdfStartedNanos = System.nanoTime();
             byte[] pdfBytes = pdfGenerator.generate(html);
+            log.info(
+                    "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage=pdf_generation durationMs={} pdfByteLength={}",
+                    processingReport.reportId(),
+                    processingReport.reportType(),
+                    elapsedMillis(pdfStartedNanos),
+                    pdfBytes.length);
+
+            stage = "storage_upload";
+            long uploadStartedNanos = System.nanoTime();
+            String fileName = fileNameOf(processingReport);
             ExamSprintReportStorage.StoredExamSprintReportFile storedFile = storage.upload(
                     processingReport.reportId(),
                     processingReport.reportType(),
-                    fileNameOf(processingReport),
+                    fileName,
                     pdfBytes,
                     processingReport.expiresAt());
-            return Optional.of(repository.save(repository.findById(reportId)
-                    .orElseThrow()
-                    .success(clock.instant(), storedFile.storageObjectKey(), storedFile.fileName())));
+            log.info(
+                    "exam_sprint_report_generation_stage_completed reportId={} reportType={} stage=storage_upload durationMs={} storageObjectKey={} fileName={}",
+                    processingReport.reportId(),
+                    processingReport.reportType(),
+                    elapsedMillis(uploadStartedNanos),
+                    storedFile.storageObjectKey(),
+                    storedFile.fileName());
+
+            stage = "final_success_save";
+            Optional<ExamSprintReport> currentReport = repository.findById(reportId);
+            if (currentReport.isEmpty()) {
+                log.info(
+                        "exam_sprint_report_generation_skipped reportId={} reportType={} stage=success_save reason=not_found_before_success_save",
+                        reportId,
+                        processingReport.reportType());
+                return Optional.empty();
+            }
+
+            ExamSprintReport successReport = repository.save(currentReport.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(startedNanos),
+                    successReport.storageObjectKey(),
+                    successReport.fileName());
+            return Optional.of(successReport);
         } catch (Exception exception) {
+            log.error(
+                    "exam_sprint_report_generation_failed reportId={} reportType={} stage={} failureReason={} exceptionType={} durationMs={}",
+                    reportId,
+                    processingReport.reportType(),
+                    stage,
+                    safeFailureReasonForLog(exception),
+                    exception.getClass().getSimpleName(),
+                    elapsedMillis(startedNanos));
             return repository.findById(reportId)
                     .map(current -> repository.save(current.failed(clock.instant(), failureReasonOf(exception))));
         }
@@ -78,4 +173,12 @@ public class ExamSprintReportGenerationPipeline {
         String message = exception.getMessage();
         return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
     }
+
+    private String safeFailureReasonForLog(Exception exception) {
+        return exception.getClass().getSimpleName();
+    }
+
+    private long elapsedMillis(long startedNanos) {
+        return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
+    }
 }

+ 163 - 4
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java

@@ -19,9 +19,12 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import jakarta.validation.Validation;
 import jakarta.validation.Validator;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
 
 import java.lang.reflect.RecordComponent;
 import java.net.URI;
@@ -42,6 +45,7 @@ import java.util.stream.Stream;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
+@ExtendWith(OutputCaptureExtension.class)
 class ExamSprintReportApplicationServiceTest {
 
     private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-02T00:00:00Z"), ZoneOffset.UTC);
@@ -137,6 +141,29 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(storage.generatedKeys).containsExactly(saved.storageObjectKey());
     }
 
+    @Test
+    void createOutlookReportSyncConvertsDownloadUrlGenerationFailureWithoutSensitiveLog(CapturedOutput output) {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        storage.failGenerateDownloadUrlWith(new IllegalStateException("SENSITIVE_DOWNLOAD_URL_DO_NOT_LOG"));
+        DefaultExamSprintReportApplicationService service = service(
+                repository,
+                reportId -> {
+                    throw new IllegalStateException("sync create must not dispatch async generation");
+                },
+                storage);
+
+        assertThatThrownBy(() -> service.createOutlookReportSync(validOutlookPayload()))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+
+        assertThat(output.getAll())
+                .contains("reason=download_url_generation_failed")
+                .contains("exceptionType=IllegalStateException")
+                .doesNotContain("SENSITIVE_DOWNLOAD_URL_DO_NOT_LOG");
+    }
+
     @Test
     void createOutlookReportSyncRejectsInvalidPayloadBeforeSaving() {
         assertCreateOutlookReportSyncRejectsInvalidPayload(OBJECT_MAPPER.nullNode());
@@ -199,7 +226,7 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void createOutlookReportReturnsFailedStatusWhenDispatchFails() {
+    void createOutlookReportReturnsFailedStatusWhenDispatchFails(CapturedOutput output) {
         TestRepository repository = new TestRepository();
         DefaultExamSprintReportApplicationService service = service(
                 repository,
@@ -218,6 +245,15 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(saved.failureReason()).doesNotContain("task-executor-7");
         assertThat(saved.failureReason()).doesNotContain("dispatcher unavailable");
         assertThat(repository.countByStatus(ExamSprintReportGenerationStatus.PENDING)).isZero();
+        assertThat(output.getAll())
+                .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");
     }
 
     @Test
@@ -235,7 +271,7 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void getReportReturnsDownloadUrlForSuccessfulReport() {
+    void getReportReturnsDownloadUrlForSuccessfulReport(CapturedOutput output) {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         ExamSprintReport report = ExamSprintReport.pending(
@@ -257,10 +293,45 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/report-success/download");
         assertThat(storage.generatedKeys)
                 .containsExactly("exam-sprint-achievement-report-report-success.pdf");
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_query_completed")
+                .contains("reportId=report-success")
+                .contains("reportType=ACHIEVEMENT")
+                .contains("generationStatus=SUCCESS")
+                .contains("downloadUrlIncluded=true");
     }
 
     @Test
-    void downloadReportRejectsExpiredReportBeforeCleanupRuns() {
+    void getReportKeepsResponseWhenDownloadUrlGenerationFailsWithoutSensitiveLog(CapturedOutput output) {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        ExamSprintReport report = ExamSprintReport.pending(
+                        "report-query-url-failure",
+                        ExamSprintReportType.ACHIEVEMENT,
+                        validAchievementPayload(),
+                        FIXED_CLOCK.instant().minusSeconds(120),
+                        FIXED_CLOCK.instant().plusSeconds(3600))
+                .success(
+                        FIXED_CLOCK.instant().minusSeconds(30),
+                        "exam-sprint-achievement-report-report-query-url-failure.pdf",
+                        "exam-sprint-achievement-report-report-query-url-failure.pdf");
+        repository.save(report);
+        storage.failGenerateDownloadUrlWith(new IllegalStateException("SENSITIVE_QUERY_URL_DO_NOT_LOG"));
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
+
+        var response = service.getReport("report-query-url-failure");
+
+        assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
+        assertThat(response.downloadUrl()).isNull();
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_download_url_generation_failed")
+                .contains("exceptionType=IllegalStateException")
+                .contains("storageObjectKey=exam-sprint-achievement-report-report-query-url-failure.pdf")
+                .doesNotContain("SENSITIVE_QUERY_URL_DO_NOT_LOG");
+    }
+
+    @Test
+    void downloadReportRejectsExpiredReportBeforeCleanupRuns(CapturedOutput output) {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         repository.save(ExamSprintReport.pending(
@@ -278,6 +349,68 @@ class ExamSprintReportApplicationServiceTest {
                 .isInstanceOf(BusinessException.class)
                 .extracting(exception -> ((BusinessException) exception).getErrorCode())
                 .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_download_unavailable")
+                .contains("reportId=report-expired")
+                .contains("generationStatus=EXPIRED")
+                .contains("reason=expired");
+    }
+
+    @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.getAll())
+                .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");
+    }
+
+    @Test
+    void downloadReportConvertsStorageDownloadFailureWithoutSensitiveLog(CapturedOutput output) {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        repository.save(ExamSprintReport.pending(
+                "report-storage-download-failure",
+                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-storage-download-failure.pdf",
+                "exam-sprint-outlook-report-report-storage-download-failure.pdf"));
+        storage.failDownloadWith(new IllegalStateException("SENSITIVE_STORAGE_DOWNLOAD_DO_NOT_LOG"));
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
+
+        assertThatThrownBy(() -> service.downloadReport("report-storage-download-failure"))
+                .isInstanceOf(BusinessException.class)
+                .extracting(exception -> ((BusinessException) exception).getErrorCode())
+                .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
+
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_download_unavailable")
+                .contains("reason=storage_download_failed")
+                .contains("exceptionType=IllegalStateException")
+                .doesNotContain("SENSITIVE_STORAGE_DOWNLOAD_DO_NOT_LOG");
     }
 
     @Test
@@ -299,7 +432,7 @@ class ExamSprintReportApplicationServiceTest {
     }
 
     @Test
-    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails() {
+    void cleanupExpiredReportsContinuesWhenDeletingOneReportFails(CapturedOutput output) {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         repository.save(ExamSprintReport.pending(
@@ -334,6 +467,16 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(storage.deletedKeys)
                 .contains("first.pdf")
                 .contains("second.pdf");
+        assertThat(output.getAll())
+                .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");
     }
 
     private DefaultExamSprintReportApplicationService service(
@@ -574,6 +717,8 @@ class ExamSprintReportApplicationServiceTest {
         private final List<String> generatedKeys = new ArrayList<>();
         private final List<String> deletedKeys = new ArrayList<>();
         private final List<String> deleteFailures = new ArrayList<>();
+        private RuntimeException generateDownloadUrlFailure;
+        private RuntimeException downloadFailure;
 
         @Override
         public StoredExamSprintReportFile upload(
@@ -587,12 +732,18 @@ class ExamSprintReportApplicationServiceTest {
 
         @Override
         public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
+            if (generateDownloadUrlFailure != null) {
+                throw generateDownloadUrlFailure;
+            }
             generatedKeys.add(storageObjectKey);
             return URI.create("/api/exam-sprint/reports/" + reportIdFromStorageObjectKey(storageObjectKey) + "/download");
         }
 
         @Override
         public Optional<StoredExamSprintReportContent> download(String storageObjectKey) {
+            if (downloadFailure != null) {
+                throw downloadFailure;
+            }
             return Optional.empty();
         }
 
@@ -608,6 +759,14 @@ class ExamSprintReportApplicationServiceTest {
             deleteFailures.add(storageObjectKey);
         }
 
+        void failGenerateDownloadUrlWith(RuntimeException failure) {
+            generateDownloadUrlFailure = failure;
+        }
+
+        void failDownloadWith(RuntimeException failure) {
+            downloadFailure = failure;
+        }
+
         private String reportIdFromStorageObjectKey(String storageObjectKey) {
             String fileName = storageObjectKey.substring(storageObjectKey.lastIndexOf('/') + 1);
             String outlookPrefix = "exam-sprint-outlook-report-";

+ 114 - 1
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

@@ -8,6 +8,9 @@ import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepo
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
 
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
@@ -22,6 +25,7 @@ import java.util.concurrent.ConcurrentMap;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
+@ExtendWith(OutputCaptureExtension.class)
 class ExamSprintReportGenerationWorkerTest {
 
     private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
@@ -47,6 +51,34 @@ class ExamSprintReportGenerationWorkerTest {
         assertThat(report.fileName()).isEqualTo("exam-sprint-outlook-report-report-success.pdf");
     }
 
+    @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 SensitiveHtmlRenderer()), storage);
+
+        worker.process("report-log-success");
+
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_generation_started")
+                .contains("reportId=report-log-success")
+                .contains("reportType=OUTLOOK")
+                .contains("stage=render_html")
+                .contains("stage=pdf_generation")
+                .contains("pdfByteLength=")
+                .contains("stage=storage_upload")
+                .contains("exam_sprint_report_generation_succeeded")
+                .contains("storageObjectKey=exam-sprint-outlook-report-report-log-success.pdf")
+                .doesNotContain("SENSITIVE_HTML_DO_NOT_LOG")
+                .doesNotContain("<html><body>SENSITIVE_HTML_DO_NOT_LOG</body></html>");
+    }
+
     @Test
     void processCreatesAchievementFileNameAndStorageKeyAfterUpload() {
         TestRepository repository = new TestRepository();
@@ -93,7 +125,61 @@ class ExamSprintReportGenerationWorkerTest {
     }
 
     @Test
-    void processDoesNotResurrectReportDeletedBeforeFinalSuccessSave() {
+    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.getAll())
+                .contains("exam_sprint_report_generation_failed")
+                .contains("reportId=report-log-failed")
+                .contains("reportType=OUTLOOK")
+                .contains("stage=render_html")
+                .contains("failureReason=IllegalStateException")
+                .contains("exceptionType=IllegalStateException")
+                .doesNotContain("renderer exploded");
+    }
+
+    @Test
+    void processPersistsRawFailureReasonButLogsSafeFailureReason(CapturedOutput output) {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-sensitive-failed",
+                ExamSprintReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode(),
+                FIXED_CLOCK.instant(),
+                FIXED_CLOCK.instant().plusSeconds(86400)));
+
+        ExamSprintReportGenerationWorker worker = createWorker(
+                repository,
+                List.of(new SensitiveFailureRenderer()),
+                new TestStorage());
+
+        worker.process("report-sensitive-failed");
+
+        ExamSprintReport report = repository.findById("report-sensitive-failed").orElseThrow();
+        assertThat(report.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.FAILED);
+        assertThat(report.failureReason()).isEqualTo("<html>SENSITIVE_FAILURE_DO_NOT_LOG</html>");
+        assertThat(output.getAll())
+                .contains("exam_sprint_report_generation_failed")
+                .contains("failureReason=IllegalStateException")
+                .doesNotContain("SENSITIVE_FAILURE_DO_NOT_LOG")
+                .doesNotContain("<html>SENSITIVE_FAILURE_DO_NOT_LOG</html>");
+    }
+
+    @Test
+    void processDoesNotResurrectReportDeletedBeforeFinalSuccessSave(CapturedOutput output) {
         TestRepository repository = new TestRepository();
         repository.save(ExamSprintReport.pending(
                 "report-deleted",
@@ -108,6 +194,9 @@ class ExamSprintReportGenerationWorkerTest {
         worker.process("report-deleted");
 
         assertThat(repository.findById("report-deleted")).isEmpty();
+        assertThat(output.getAll())
+                .contains("reason=not_found_before_success_save")
+                .doesNotContain("exam_sprint_report_generation_failed");
     }
 
     private ExamSprintReportGenerationWorker createWorker(
@@ -157,6 +246,30 @@ class ExamSprintReportGenerationWorkerTest {
         }
     }
 
+    private static class SensitiveHtmlRenderer implements ExamSprintReportRenderer {
+        @Override
+        public boolean supports(ExamSprintReportType reportType) {
+            return reportType == ExamSprintReportType.OUTLOOK;
+        }
+
+        @Override
+        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+            return "<html><body>SENSITIVE_HTML_DO_NOT_LOG</body></html>";
+        }
+    }
+
+    private static class SensitiveFailureRenderer implements ExamSprintReportRenderer {
+        @Override
+        public boolean supports(ExamSprintReportType reportType) {
+            return reportType == ExamSprintReportType.OUTLOOK;
+        }
+
+        @Override
+        public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
+            throw new IllegalStateException("<html>SENSITIVE_FAILURE_DO_NOT_LOG</html>");
+        }
+    }
+
     private static class TestRepository implements ExamSprintReportRepository {
         private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
 

+ 789 - 0
docs/superpowers/plans/2026-04-27-exam-sprint-report-logging.md

@@ -0,0 +1,789 @@
+# 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.

+ 105 - 0
docs/superpowers/specs/2026-04-27-exam-sprint-report-logging-design.md

@@ -0,0 +1,105 @@
+# Exam Sprint Report Logging Design
+
+## Goal
+
+Enrich business logs for the two exam-sprint report types, `OUTLOOK` and `ACHIEVEMENT`, so production issues can be located from report submission through PDF generation, storage, query, download, and cleanup.
+
+## Scope
+
+The logging enhancement covers these report APIs and application flows:
+
+- Asynchronous creation: `/api/exam-sprint/outlook-reports` and `/api/exam-sprint/achievement-reports`
+- Synchronous creation: `/api/exam-sprint/outlook-reports/sync` and `/api/exam-sprint/achievement-reports/sync`
+- Report status query: `/api/exam-sprint/reports/{reportId}`
+- PDF download: `/api/exam-sprint/reports/{reportId}/download`
+- Background generation pipeline and expired report cleanup
+
+The implementation should not log full request payloads, rendered HTML, PDF bytes, storage credentials, or account keys.
+
+## Recommended Approach
+
+Use focused, structured business logs at application and pipeline boundaries. This keeps the log volume controlled while exposing the information needed to diagnose common failures.
+
+The primary log fields should be:
+
+- `reportId`
+- `reportType`
+- `generationStatus`
+- `stage`
+- `durationMs` where a stage has meaningful elapsed time
+- `storageObjectKey` and `fileName` after upload or download lookup
+- concise failure reason and exception type for failures
+
+## Components
+
+### `DefaultExamSprintReportApplicationService`
+
+Add logs around public application operations:
+
+- Asynchronous report submission:
+  - `INFO` after the report is persisted and submitted to the dispatcher.
+  - `WARN` if dispatch fails and the report is marked failed.
+- Synchronous report creation:
+  - `INFO` when synchronous generation starts.
+  - `INFO` when synchronous generation succeeds and a download URL can be returned.
+  - `WARN` when the generated report is not downloadable.
+- Report query:
+  - `INFO` with report status and whether a download URL is included.
+  - `INFO` when an expired report is marked expired during query.
+- Report download:
+  - `INFO` on download start and success.
+  - `WARN` when the report is expired, not successful, has no storage key, or storage content is missing.
+- Expired report cleanup:
+  - `INFO` at cleanup summary level with scanned, expired, storage-cleared, and failed counts.
+  - `WARN` for individual cleanup failures while preserving retry behavior.
+
+### `ExamSprintReportGenerationPipeline`
+
+Add stage-based logs around generation:
+
+- `INFO` when generation starts for a pending report.
+- `INFO` when a report is skipped because it is missing, not pending, or expired.
+- `INFO` when status changes to `PROCESSING`.
+- `INFO` after HTML rendering with elapsed time and HTML length.
+- `INFO` after PDF generation with elapsed time and byte size.
+- `INFO` after storage upload with elapsed time, storage object key, and file name.
+- `INFO` after final success with total elapsed time.
+- `ERROR` when any generation stage fails, including report id, report type, stage, failure reason, and exception.
+
+### Infrastructure Components
+
+Keep infrastructure logs minimal. Application and pipeline logs should be enough for the normal diagnosis path. Add infrastructure-level logging only if a component has a meaningful failure boundary that is otherwise invisible.
+
+For this change, avoid verbose logs in renderers, PDF generator, and Azure storage unless implementation reveals an unlogged failure path.
+
+## Data Flow
+
+1. The controller delegates to `DefaultExamSprintReportApplicationService`.
+2. The application service validates payload shape and creates a pending `ExamSprintReport`.
+3. For asynchronous generation, it persists the report and dispatches the `reportId`.
+4. For synchronous generation, it persists the report and calls `ExamSprintReportGenerationPipeline.generate(reportId)` directly.
+5. The pipeline moves the report to `PROCESSING`, renders HTML, generates PDF bytes, uploads the PDF, and marks the report `SUCCESS`.
+6. If an exception occurs, the pipeline marks the report `FAILED` and logs the failed stage.
+7. Query and download operations log current status and download availability without logging sensitive content.
+
+## Error Handling
+
+Generation failures should continue to use the existing behavior: catch the exception, persist a failed report with `failureReason`, and return the failed report where applicable.
+
+The logs should make failure causes observable without changing external API behavior. Expected business states such as expired reports or unavailable downloads should use `WARN`, while unexpected generation exceptions should use `ERROR`.
+
+## Testing Strategy
+
+Add or update unit tests where feasible to verify that logging changes do not alter behavior:
+
+- Existing application service tests should continue to pass for asynchronous creation, synchronous creation, query, download, and cleanup flows.
+- Existing pipeline tests should continue to pass for success and failure flows.
+- If the project already uses log-capture testing utilities, add focused assertions for at least one successful generation log and one failed generation log. Otherwise, avoid brittle log text assertions and rely on behavior-preserving tests.
+
+## Acceptance Criteria
+
+- Both `OUTLOOK` and `ACHIEVEMENT` report generation paths emit useful logs with `reportId` and `reportType`.
+- Generation logs identify the stage where rendering, PDF generation, or upload fails.
+- Query and download logs distinguish expired, unavailable, missing-storage, and successful download cases.
+- Logs do not include full payloads, HTML content, PDF bytes, storage credentials, or account keys.
+- Existing tests for report creation, generation, query, download, and cleanup continue to pass.