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.
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
Files:
Modify: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java
[ ] Step 1: Add log-capture imports
Add these imports after the existing JUnit import block:
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
Change the class declaration from:
class ExamSprintReportGenerationWorkerTest {
to:
@ExtendWith(OutputCaptureExtension.class)
class ExamSprintReportGenerationWorkerTest {
Insert this test after processMarksReportSuccessAfterUpload():
@Test
void processLogsSuccessfulGenerationStages(CapturedOutput output) {
TestRepository repository = new TestRepository();
repository.save(ExamSprintReport.pending(
"report-log-success",
ExamSprintReportType.OUTLOOK,
OBJECT_MAPPER.createObjectNode(),
FIXED_CLOCK.instant(),
FIXED_CLOCK.instant().plusSeconds(86400)));
TestStorage storage = new TestStorage();
ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);
worker.process("report-log-success");
assertThat(output.getOut())
.contains("exam_sprint_report_generation_started")
.contains("reportId=report-log-success")
.contains("reportType=OUTLOOK")
.contains("stage=render_html")
.contains("stage=pdf_generation")
.contains("stage=storage_upload")
.contains("exam_sprint_report_generation_succeeded")
.contains("storageObjectKey=exam-sprint-outlook-report-report-log-success.pdf")
.doesNotContain("<html><body>ok</body></html>");
}
Insert this test after processMarksReportFailedWhenGenerationPipelineThrows():
@Test
void processLogsFailedGenerationStage(CapturedOutput output) {
TestRepository repository = new TestRepository();
repository.save(ExamSprintReport.pending(
"report-log-failed",
ExamSprintReportType.OUTLOOK,
OBJECT_MAPPER.createObjectNode(),
FIXED_CLOCK.instant(),
FIXED_CLOCK.instant().plusSeconds(86400)));
ExamSprintReportGenerationWorker worker = createWorker(
repository,
List.of(new FailingRenderer()),
new TestStorage());
worker.process("report-log-failed");
assertThat(output.getOut())
.contains("exam_sprint_report_generation_failed")
.contains("reportId=report-log-failed")
.contains("reportType=OUTLOOK")
.contains("stage=render_html")
.contains("failureReason=renderer exploded")
.contains("exceptionType=IllegalStateException");
}
Run:
mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest test
Expected: FAIL because the application module does not yet emit exam_sprint_report_generation_started, stage=render_html, or exam_sprint_report_generation_failed logs.
Files:
Modify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java
[ ] Step 1: Add logger and timing imports
Add these imports:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.TimeUnit;
Inside the class, above the existing fields, add:
private static final Logger log = LoggerFactory.getLogger(ExamSprintReportGenerationPipeline.class);
generate with the logged implementationReplace the whole generate(String reportId) method with:
public Optional<ExamSprintReport> generate(String reportId) {
long totalStartedNanos = System.nanoTime();
Instant startedAt = clock.instant();
ExamSprintReport report = repository.findById(reportId).orElse(null);
if (report == null) {
log.info("exam_sprint_report_generation_skipped reportId={} reason=not_found", reportId);
return Optional.empty();
}
if (report.generationStatus() != ExamSprintReportGenerationStatus.PENDING) {
log.info(
"exam_sprint_report_generation_skipped reportId={} reportType={} generationStatus={} reason=not_pending",
report.reportId(),
report.reportType(),
report.generationStatus());
return Optional.empty();
}
if (report.isExpiredAt(startedAt)) {
log.info(
"exam_sprint_report_generation_skipped reportId={} reportType={} generationStatus={} reason=expired expiresAt={}",
report.reportId(),
report.reportType(),
report.generationStatus(),
report.expiresAt());
return Optional.empty();
}
log.info(
"exam_sprint_report_generation_started reportId={} reportType={} generationStatus={} startedAt={}",
report.reportId(),
report.reportType(),
report.generationStatus(),
startedAt);
ExamSprintReport processingReport = repository.save(report.processing(startedAt));
log.info(
"exam_sprint_report_generation_processing reportId={} reportType={} generationStatus={}",
processingReport.reportId(),
processingReport.reportType(),
processingReport.generationStatus());
String stage = "render_html";
try {
long stageStartedNanos = System.nanoTime();
String html = rendererFor(processingReport).render(processingReport.payload(), startedAt);
log.info(
"exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} htmlLength={}",
processingReport.reportId(),
processingReport.reportType(),
stage,
elapsedMillis(stageStartedNanos),
html.length());
stage = "pdf_generation";
stageStartedNanos = System.nanoTime();
byte[] pdfBytes = pdfGenerator.generate(html);
log.info(
"exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} pdfBytes={}",
processingReport.reportId(),
processingReport.reportType(),
stage,
elapsedMillis(stageStartedNanos),
pdfBytes.length);
stage = "storage_upload";
stageStartedNanos = System.nanoTime();
ExamSprintReportStorage.StoredExamSprintReportFile storedFile = storage.upload(
processingReport.reportId(),
processingReport.reportType(),
fileNameOf(processingReport),
pdfBytes,
processingReport.expiresAt());
log.info(
"exam_sprint_report_generation_stage_completed reportId={} reportType={} stage={} durationMs={} storageObjectKey={} fileName={}",
processingReport.reportId(),
processingReport.reportType(),
stage,
elapsedMillis(stageStartedNanos),
storedFile.storageObjectKey(),
storedFile.fileName());
stage = "success_save";
ExamSprintReport successReport = repository.save(repository.findById(reportId)
.orElseThrow()
.success(clock.instant(), storedFile.storageObjectKey(), storedFile.fileName()));
log.info(
"exam_sprint_report_generation_succeeded reportId={} reportType={} generationStatus={} durationMs={} storageObjectKey={} fileName={}",
successReport.reportId(),
successReport.reportType(),
successReport.generationStatus(),
elapsedMillis(totalStartedNanos),
successReport.storageObjectKey(),
successReport.fileName());
return Optional.of(successReport);
} catch (Exception exception) {
String failureReason = failureReasonOf(exception);
log.error(
"exam_sprint_report_generation_failed reportId={} reportType={} stage={} failureReason={} exceptionType={} durationMs={}",
processingReport.reportId(),
processingReport.reportType(),
stage,
failureReason,
exception.getClass().getSimpleName(),
elapsedMillis(totalStartedNanos),
exception);
return repository.findById(reportId)
.map(current -> repository.save(current.failed(clock.instant(), failureReason)));
}
}
Add this method below fileNameOf and above failureReasonOf:
private long elapsedMillis(long startedNanos) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
}
Run:
mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest test
Expected: PASS. The output-capture tests should find generation start, stage completion, success, and failure logs.
Files:
Modify: abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
[ ] Step 1: Add log-capture imports
Add these imports after the existing JUnit imports:
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
Change the class declaration from:
class ExamSprintReportApplicationServiceTest {
to:
@ExtendWith(OutputCaptureExtension.class)
class ExamSprintReportApplicationServiceTest {
Change the method signature from:
void createOutlookReportReturnsFailedStatusWhenDispatchFails() {
to:
void createOutlookReportReturnsFailedStatusWhenDispatchFails(CapturedOutput output) {
At the end of that test, after the existing status and failure-reason assertions, add:
assertThat(output.getOut())
.contains("exam_sprint_report_submitted")
.contains("reportType=OUTLOOK")
.contains("mode=async")
.contains("exam_sprint_report_dispatch_failed")
.contains("failureReason=report_generation_dispatch_failed")
.contains("exceptionType=IllegalStateException")
.doesNotContain("task-executor-7 rejected")
.doesNotContain("dispatcher unavailable");
Change the method signature from:
void getReportReturnsDownloadUrlForSuccessfulReport() {
to:
void getReportReturnsDownloadUrlForSuccessfulReport(CapturedOutput output) {
At the end of that test, add:
assertThat(output.getOut())
.contains("exam_sprint_report_query_completed")
.contains("reportId=report-success")
.contains("reportType=ACHIEVEMENT")
.contains("generationStatus=SUCCESS")
.contains("downloadUrlIncluded=true");
Insert this test after downloadReportRejectsExpiredReportBeforeCleanupRuns():
@Test
void downloadReportLogsMissingStorageContent(CapturedOutput output) {
TestRepository repository = new TestRepository();
TestStorage storage = new TestStorage();
repository.save(ExamSprintReport.pending(
"report-missing-content",
ExamSprintReportType.OUTLOOK,
OBJECT_MAPPER.createObjectNode(),
FIXED_CLOCK.instant().minusSeconds(600),
FIXED_CLOCK.instant().plusSeconds(3600)).success(
FIXED_CLOCK.instant().minusSeconds(300),
"exam-sprint-outlook-report-report-missing-content.pdf",
"exam-sprint-outlook-report-report-missing-content.pdf"));
DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
assertThatThrownBy(() -> service.downloadReport("report-missing-content"))
.isInstanceOf(BusinessException.class)
.extracting(exception -> ((BusinessException) exception).getErrorCode())
.isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
assertThat(output.getOut())
.contains("exam_sprint_report_download_started")
.contains("reportId=report-missing-content")
.contains("reportType=OUTLOOK")
.contains("exam_sprint_report_download_missing_storage_content")
.contains("storageObjectKey=exam-sprint-outlook-report-report-missing-content.pdf");
}
Change the method signature from:
void cleanupExpiredReportsContinuesWhenDeletingOneReportFails() {
to:
void cleanupExpiredReportsContinuesWhenDeletingOneReportFails(CapturedOutput output) {
At the end of that test, add:
assertThat(output.getOut())
.contains("exam_sprint_report_cleanup_item_failed")
.contains("reportId=report-delete-fails")
.contains("storageObjectKey=first.pdf")
.contains("exceptionType=IllegalStateException")
.contains("exam_sprint_report_cleanup_completed")
.contains("scannedCount=2")
.contains("storageClearedCount=1")
.contains("markedExpiredCount=0")
.contains("failedCount=1");
Run:
mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test
Expected: FAIL because DefaultExamSprintReportApplicationService does not yet emit exam_sprint_report_submitted, exam_sprint_report_query_completed, download missing-storage, or cleanup summary logs.
Files:
Modify: abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java
[ ] Step 1: Add logger, optional, and timing imports
Add these imports:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
Keep the existing java.util.Set and java.util.UUID imports.
Inside the class, above REPORT_GENERATION_DISPATCH_FAILED, add:
private static final Logger log = LoggerFactory.getLogger(DefaultExamSprintReportApplicationService.class);
submitReportGeneration with the logged implementationReplace the whole submitReportGeneration(ExamSprintReportType reportType, JsonNode payload) method with:
private CreateExamSprintReportResponse submitReportGeneration(ExamSprintReportType reportType, JsonNode payload) {
Instant now = clock.instant();
ExamSprintReport report = ExamSprintReport.pending(
UUID.randomUUID().toString(),
reportType,
payload,
now,
now.plus(properties.getRetention()));
repository.save(report);
log.info(
"exam_sprint_report_submitted reportId={} reportType={} generationStatus={} expiresAt={} mode=async",
report.reportId(),
report.reportType(),
report.generationStatus(),
report.expiresAt());
try {
dispatcher.dispatch(report.reportId());
log.info(
"exam_sprint_report_dispatched reportId={} reportType={} mode=async",
report.reportId(),
report.reportType());
} catch (RuntimeException exception) {
report = repository.save(report.failed(now, dispatchFailureReason(exception)));
log.warn(
"exam_sprint_report_dispatch_failed reportId={} reportType={} failureReason={} exceptionType={}",
report.reportId(),
report.reportType(),
report.failureReason(),
exception.getClass().getSimpleName());
}
return new CreateExamSprintReportResponse(
report.reportId(),
report.reportType(),
report.generationStatus(),
report.createdAt(),
report.expiresAt());
}
submitReportGenerationSync with the logged implementationReplace the whole submitReportGenerationSync(ExamSprintReportType reportType, JsonNode payload) method with:
private CreateExamSprintReportWithUrlResponse submitReportGenerationSync(
ExamSprintReportType reportType,
JsonNode payload) {
long startedNanos = System.nanoTime();
Instant now = clock.instant();
ExamSprintReport report = ExamSprintReport.pending(
UUID.randomUUID().toString(),
reportType,
payload,
now,
now.plus(properties.getRetention()));
repository.save(report);
log.info(
"exam_sprint_report_sync_generation_started reportId={} reportType={} generationStatus={} expiresAt={}",
report.reportId(),
report.reportType(),
report.generationStatus(),
report.expiresAt());
Optional<ExamSprintReport> generatedReportOptional = pipeline.generate(report.reportId());
if (generatedReportOptional.isEmpty()) {
log.warn(
"exam_sprint_report_sync_generation_unavailable reportId={} reportType={} reason=pipeline_empty durationMs={}",
report.reportId(),
report.reportType(),
elapsedMillis(startedNanos));
throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
}
ExamSprintReport generatedReport = generatedReportOptional.get();
if (generatedReport.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS
|| generatedReport.storageObjectKey() == null) {
log.warn(
"exam_sprint_report_sync_generation_unavailable reportId={} reportType={} generationStatus={} storageObjectKeyPresent={} durationMs={}",
generatedReport.reportId(),
generatedReport.reportType(),
generatedReport.generationStatus(),
generatedReport.storageObjectKey() != null,
elapsedMillis(startedNanos));
throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
}
String downloadUrl = storage.generateDownloadUrl(
generatedReport.storageObjectKey(),
properties.getDownloadExpiry()).toString();
log.info(
"exam_sprint_report_sync_generation_succeeded reportId={} reportType={} generationStatus={} durationMs={} storageObjectKey={}",
generatedReport.reportId(),
generatedReport.reportType(),
generatedReport.generationStatus(),
elapsedMillis(startedNanos),
generatedReport.storageObjectKey());
return new CreateExamSprintReportWithUrlResponse(
generatedReport.reportId(),
generatedReport.reportType(),
generatedReport.generationStatus(),
generatedReport.createdAt(),
generatedReport.updatedAt(),
generatedReport.expiresAt(),
downloadUrl);
}
getReport with the logged implementationReplace the whole getReport(String reportId) method with:
@Override
public ExamSprintReportDetailResponse getReport(String reportId) {
Instant now = clock.instant();
ExamSprintReport report = requireReport(reportId);
if (report.isExpiredAt(now) && report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
report = repository.save(report.expired(now));
log.info(
"exam_sprint_report_marked_expired_on_query reportId={} reportType={} generationStatus={} expiresAt={}",
report.reportId(),
report.reportType(),
report.generationStatus(),
report.expiresAt());
}
String downloadUrl = null;
if (report.generationStatus() == ExamSprintReportGenerationStatus.SUCCESS
&& !report.isExpiredAt(now)
&& report.storageObjectKey() != null) {
downloadUrl = storage.generateDownloadUrl(report.storageObjectKey(), properties.getDownloadExpiry()).toString();
}
log.info(
"exam_sprint_report_query_completed reportId={} reportType={} generationStatus={} downloadUrlIncluded={} storageObjectKeyPresent={}",
report.reportId(),
report.reportType(),
report.generationStatus(),
downloadUrl != null,
report.storageObjectKey() != null);
return new ExamSprintReportDetailResponse(
report.reportId(),
report.reportType(),
report.generationStatus(),
report.createdAt(),
report.updatedAt(),
report.expiresAt(),
downloadUrl,
report.failureReason());
}
downloadReport with the logged implementationReplace the whole downloadReport(String reportId) method with:
@Override
public ReportDownloadContent downloadReport(String reportId) {
Instant now = clock.instant();
ExamSprintReport report = requireReport(reportId);
log.info(
"exam_sprint_report_download_started reportId={} reportType={} generationStatus={}",
report.reportId(),
report.reportType(),
report.generationStatus());
if (report.isExpiredAt(now)) {
if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
repository.save(report.expired(now));
}
log.warn(
"exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=expired expiresAt={}",
report.reportId(),
report.reportType(),
report.generationStatus(),
report.expiresAt());
throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
}
if (report.generationStatus() != ExamSprintReportGenerationStatus.SUCCESS) {
log.warn(
"exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=not_success",
report.reportId(),
report.reportType(),
report.generationStatus());
throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
}
if (report.storageObjectKey() == null) {
log.warn(
"exam_sprint_report_download_unavailable reportId={} reportType={} generationStatus={} reason=missing_storage_key",
report.reportId(),
report.reportType(),
report.generationStatus());
throw new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
}
return storage.download(report.storageObjectKey())
.map(content -> {
log.info(
"exam_sprint_report_download_succeeded reportId={} reportType={} storageObjectKey={} fileName={} bytes={}",
report.reportId(),
report.reportType(),
report.storageObjectKey(),
content.fileName(),
content.bytes().length);
return new ReportDownloadContent(content.fileName(), content.bytes(), content.contentType());
})
.orElseThrow(() -> {
log.warn(
"exam_sprint_report_download_missing_storage_content reportId={} reportType={} storageObjectKey={}",
report.reportId(),
report.reportType(),
report.storageObjectKey());
return new BusinessException(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
});
}
cleanupExpiredReports with the logged implementationReplace the whole cleanupExpiredReports() method with:
public void cleanupExpiredReports() {
Instant now = clock.instant();
List<ExamSprintReport> expiredReports = repository.findExpiredAtOrBefore(now);
int storageClearedCount = 0;
int markedExpiredCount = 0;
int failedCount = 0;
for (ExamSprintReport report : expiredReports) {
try {
if (report.storageObjectKey() != null) {
storage.delete(report.storageObjectKey());
repository.save(report.expiredWithStorageCleared(now));
storageClearedCount++;
} else if (report.generationStatus() != ExamSprintReportGenerationStatus.EXPIRED) {
repository.save(report.expired(now));
markedExpiredCount++;
}
} catch (RuntimeException exception) {
failedCount++;
log.warn(
"exam_sprint_report_cleanup_item_failed reportId={} reportType={} generationStatus={} storageObjectKey={} exceptionType={}",
report.reportId(),
report.reportType(),
report.generationStatus(),
report.storageObjectKey(),
exception.getClass().getSimpleName());
}
}
log.info(
"exam_sprint_report_cleanup_completed scannedCount={} storageClearedCount={} markedExpiredCount={} failedCount={}",
expiredReports.size(),
storageClearedCount,
markedExpiredCount,
failedCount);
}
Add this method below requireReport and above dispatchFailureReason:
private long elapsedMillis(long startedNanos) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos);
}
Run:
mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportApplicationServiceTest test
Expected: PASS. The output-capture tests should find async submit, dispatch failure, query, missing storage download, and cleanup logs.
Files:
Verify only; no additional source edits expected.
[ ] Step 1: Run the application report tests together
Run:
mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportGenerationWorkerTest,ExamSprintReportApplicationServiceTest test
Expected: PASS.
Run:
mvn -pl abilities/exam-sprint/application -am test
Expected: PASS.
Run:
git diff -- abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java docs/superpowers/specs/2026-04-27-exam-sprint-report-logging-design.md docs/superpowers/plans/2026-04-27-exam-sprint-report-logging.md
Expected: Diff contains reportId, reportType, generationStatus, stage, durationMs, storageObjectKey, and fileName logs. Diff does not log full payload JSON, rendered HTML, PDF bytes content, Azure connection strings, Azure account names, Azure account keys, or complete exception messages for async dispatch failure.
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.