From 76ea5867198e71203f3e136518db9504a784de99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= <258399167+xiaoyaner0201@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:11:03 +0800 Subject: [PATCH 01/21] feat(domain): capture immutable context for business event producers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com> --- .../domain/event/BusinessEventSnapshot.java | 24 +++++++++++++++++++ .../domain/event/PromotionApprovedEvent.java | 7 +++++- .../domain/event/PromotionRejectedEvent.java | 7 +++++- .../domain/event/ReviewApprovedEvent.java | 7 +++++- .../domain/event/ReviewRejectedEvent.java | 7 +++++- .../domain/event/SkillBusinessEvent.java | 7 ++++++ .../domain/event/SkillDownloadedEvent.java | 7 +++++- .../domain/event/SkillPublishedEvent.java | 7 +++++- .../domain/event/SkillStatusChangedEvent.java | 7 +++++- .../domain/event/SkillVersionYankedEvent.java | 7 +++++- .../event/SkillVisibilityChangedEvent.java | 6 ++++- .../domain/review/PromotionService.java | 12 +++++++--- .../skillhub/domain/review/ReviewService.java | 9 ++++--- .../skill/service/SkillDownloadService.java | 8 ++++--- .../skill/service/SkillGovernanceService.java | 13 ++++++---- .../skill/service/SkillPublishService.java | 6 ++++- .../service/SkillReviewSubmitService.java | 5 +++- .../skill/service/SkillVisibilityService.java | 4 +++- .../domain/review/PromotionServiceTest.java | 15 +++++++++++- .../domain/review/ReviewServiceTest.java | 8 +++++++ .../service/SkillGovernanceServiceTest.java | 15 +++++++++--- .../service/SkillPublishServiceTest.java | 8 ++++++- .../service/SkillReviewSubmitServiceTest.java | 6 +++++ .../service/SkillVisibilityServiceTest.java | 11 ++++++++- 24 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/BusinessEventSnapshot.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillBusinessEvent.java diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/BusinessEventSnapshot.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/BusinessEventSnapshot.java new file mode 100644 index 000000000..f7016563b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/BusinessEventSnapshot.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.event; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.time.Instant; + +/** + * Immutable producer context, never an entity or a request object. No telemetry dependency. + * The timestamp is event occurrence (immediately before publication), not transaction commit time. + */ +public record BusinessEventSnapshot(Long namespaceId, SkillVisibility visibility, String actorId, + String actorType, String source, Instant occurredAt) { + public static BusinessEventSnapshot capture(Skill skill, String actorId, String source) { + return capture(skill, actorId, "user", source); + } + + public static BusinessEventSnapshot capture(Skill skill, String actorId, String actorType, String source) { + // BuiltinSkillInitializer reserves this identity and verifies isSystemAccount before publishing. + // It uses the ordinary privileged publish path; never mislabel that service account as human. + if ("builtin-skill-publisher".equals(actorId)) { actorType = "system"; } + return new BusinessEventSnapshot(skill.getNamespaceId(), skill.getVisibility(), actorId, + actorType, source, Instant.now()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java index 4bb099041..b90a788f8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java @@ -1,2 +1,7 @@ package com.iflytek.skillhub.domain.event; -public record PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId) {} +public record PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId, + String submitterId, BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId) { + this(promotionId, skillId, reviewerId, submitterId, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionRejectedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionRejectedEvent.java index d3a048123..183afc07c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionRejectedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionRejectedEvent.java @@ -1,2 +1,7 @@ package com.iflytek.skillhub.domain.event; -public record PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId, String reason) {} +public record PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId, + String submitterId, String reason, BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId, String reason) { + this(promotionId, skillId, reviewerId, submitterId, reason, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java index b600c9fb7..bc00236be 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java @@ -1,2 +1,7 @@ package com.iflytek.skillhub.domain.event; -public record ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId) {} +public record ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, + String submitterId, BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId) { + this(reviewId, skillId, versionId, reviewerId, submitterId, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java index dd09b8ad6..65c2841d3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java @@ -1,2 +1,7 @@ package com.iflytek.skillhub.domain.event; -public record ReviewRejectedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId, String reason) {} +public record ReviewRejectedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, + String submitterId, String reason, BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public ReviewRejectedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId, String reason) { + this(reviewId, skillId, versionId, reviewerId, submitterId, reason, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillBusinessEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillBusinessEvent.java new file mode 100644 index 000000000..6721c31c1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillBusinessEvent.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.event; + +/** Marker for existing skill facts with a producer-captured scalar analytics snapshot. */ +public interface SkillBusinessEvent { + Long skillId(); + BusinessEventSnapshot snapshot(); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillDownloadedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillDownloadedEvent.java index 16cb28367..51cb4c35e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillDownloadedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillDownloadedEvent.java @@ -1,3 +1,8 @@ package com.iflytek.skillhub.domain.event; -public record SkillDownloadedEvent(Long skillId, Long versionId) {} +public record SkillDownloadedEvent(Long skillId, Long versionId, + BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public SkillDownloadedEvent(Long skillId, Long versionId) { + this(skillId, versionId, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java index e9e5b2b80..b914c6177 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java @@ -1,3 +1,8 @@ package com.iflytek.skillhub.domain.event; -public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId) {} +public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId, + BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public SkillPublishedEvent(Long skillId, Long versionId, String publisherId) { + this(skillId, versionId, publisherId, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillStatusChangedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillStatusChangedEvent.java index 63b89f743..13521a6f9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillStatusChangedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillStatusChangedEvent.java @@ -2,4 +2,9 @@ package com.iflytek.skillhub.domain.event; import com.iflytek.skillhub.domain.skill.SkillStatus; -public record SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus) {} +public record SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus, + BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus) { + this(skillId, oldStatus, newStatus, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVersionYankedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVersionYankedEvent.java index a9a6d92d6..24826eae2 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVersionYankedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVersionYankedEvent.java @@ -1,3 +1,8 @@ package com.iflytek.skillhub.domain.event; -public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId, boolean wasPublished) {} +public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId, boolean wasPublished, + BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId, boolean wasPublished) { + this(skillId, versionId, actorUserId, wasPublished, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVisibilityChangedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVisibilityChangedEvent.java index 522c5fe6d..00ef75d9a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVisibilityChangedEvent.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVisibilityChangedEvent.java @@ -4,4 +4,8 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; /** Access changed without publishing a new package version. */ public record SkillVisibilityChangedEvent(Long skillId, SkillVisibility previousVisibility, - SkillVisibility visibility) {} + SkillVisibility visibility, BusinessEventSnapshot snapshot) implements SkillBusinessEvent { + public SkillVisibilityChangedEvent(Long skillId, SkillVisibility previousVisibility, SkillVisibility visibility) { + this(skillId, previousVisibility, visibility, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 67ae04aba..adafa816e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.review; +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; + import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditDetail; @@ -294,10 +296,11 @@ public class PromotionService { PromotionRequest savedRequest = promotionRequestRepository.save(approvedRequest); eventPublisher.publishEvent(new SkillPublishedEvent( - targetSkill.getId(), newVersion.getId(), reviewerId)); + targetSkill.getId(), newVersion.getId(), reviewerId, + BusinessEventSnapshot.capture(targetSkill, reviewerId, "promotion"))); eventPublisher.publishEvent(new PromotionApprovedEvent( approvedRequest.getId(), approvedRequest.getSourceSkillId(), - reviewerId, approvedRequest.getSubmittedBy())); + reviewerId, approvedRequest.getSubmittedBy(), BusinessEventSnapshot.capture(sourceSkill, reviewerId, "promotion"))); governanceNotificationService.notifyUser( approvedRequest.getSubmittedBy(), "PROMOTION", @@ -396,9 +399,12 @@ public class PromotionService { } syncPromotionRequestState(request, ReviewTaskStatus.REJECTED, reviewerId, comment); entityManager.detach(request); + Skill sourceSkill = request.getSourceSkillId() == null ? null + : skillRepository.findById(request.getSourceSkillId()).orElse(null); eventPublisher.publishEvent(new PromotionRejectedEvent( request.getId(), request.getSourceSkillId(), - reviewerId, request.getSubmittedBy(), comment)); + reviewerId, request.getSubmittedBy(), comment, + sourceSkill == null ? null : BusinessEventSnapshot.capture(sourceSkill, reviewerId, "promotion"))); governanceNotificationService.notifyUser( request.getSubmittedBy(), "PROMOTION", diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index c18159ff2..fb7a497ab 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.review; +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; + import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy; import org.springframework.beans.factory.annotation.Autowired; import com.fasterxml.jackson.databind.ObjectMapper; @@ -289,10 +291,11 @@ public class ReviewService { if (avatarParticipant != null) { avatarParticipant.decide(task, true, reviewerId, comment); } eventPublisher.publishEvent(new SkillPublishedEvent( - skill.getId(), skillVersion.getId(), reviewerId)); + skill.getId(), skillVersion.getId(), reviewerId, + BusinessEventSnapshot.capture(skill, reviewerId, "review"))); eventPublisher.publishEvent(new ReviewApprovedEvent( task.getId(), skill.getId(), skillVersion.getId(), - reviewerId, task.getSubmittedBy())); + reviewerId, task.getSubmittedBy(), BusinessEventSnapshot.capture(skill, reviewerId, "review"))); governanceNotificationService.notifyUser( task.getSubmittedBy(), "REVIEW", @@ -358,7 +361,7 @@ public class ReviewService { if (avatarParticipant != null) { avatarParticipant.decide(task, false, reviewerId, comment); } eventPublisher.publishEvent(new ReviewRejectedEvent( task.getId(), skill.getId(), skillVersion.getId(), - reviewerId, task.getSubmittedBy(), comment)); + reviewerId, task.getSubmittedBy(), comment, BusinessEventSnapshot.capture(skill, reviewerId, "review"))); governanceNotificationService.notifyUser( task.getSubmittedBy(), "REVIEW", diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 67ae243ed..53591d540 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -172,15 +173,16 @@ public class SkillDownloadService { // Only increment download count for PUBLISHED versions if (version.getStatus() == SkillVersionStatus.PUBLISHED) { - recordPublishedDownload(skill, version); + recordPublishedDownload(skill, version, currentUserId); } return result; } - private void recordPublishedDownload(Skill skill, SkillVersion version) { + private void recordPublishedDownload(Skill skill, SkillVersion version, String currentUserId) { skillRepository.incrementDownloadCount(skill.getId()); skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId()); - eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); + eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId(), + BusinessEventSnapshot.capture(skill, currentUserId, "download"))); } private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 82536db4c..5161f549f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; @@ -139,7 +140,8 @@ public class SkillGovernanceService { skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, jsonReason(reason)); - eventPublisher.publishEvent(new SkillStatusChangedEvent(skill.getId(), previousStatus, SkillStatus.ARCHIVED)); + eventPublisher.publishEvent(new SkillStatusChangedEvent(skill.getId(), previousStatus, SkillStatus.ARCHIVED, + BusinessEventSnapshot.capture(skill, actorUserId, "governance"))); return saved; } @@ -180,7 +182,8 @@ public class SkillGovernanceService { skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); auditLogService.record(actorUserId, "UNARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null); - eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ACTIVE)); + eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ACTIVE, + BusinessEventSnapshot.capture(skill, actorUserId, "governance"))); return saved; } @@ -353,7 +356,8 @@ public class SkillGovernanceService { version.setYankReason(reason); version.setDownloadReady(false); SkillVersion saved = skillVersionRepository.save(version); - skillRepository.findById(version.getSkillId()).ifPresent(skill -> { + Optional eventSkill = skillRepository.findById(version.getSkillId()); + eventSkill.ifPresent(skill -> { if (versionId.equals(skill.getLatestVersionId())) { Optional fallback = findLatestPublishedVersion(skill.getId()); skill.setLatestVersionId(fallback.map(SkillVersion::getId).orElse(null)); @@ -364,7 +368,8 @@ public class SkillGovernanceService { }); auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason)); eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent( - version.getSkillId(), versionId, actorUserId, true)); + version.getSkillId(), versionId, actorUserId, true, + eventSkill.map(skill -> BusinessEventSnapshot.capture(skill, actorUserId, "governance")).orElse(null))); return saved; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index d6043acf0..c83b90c00 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.skill.service; +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; + import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy; import com.iflytek.skillhub.domain.review.ReviewAvatarParticipant; import org.springframework.beans.factory.annotation.Autowired; @@ -900,7 +902,9 @@ public class SkillPublishService { skillRepository.save(skill); if (autoPublish) { - eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); + eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId, + BusinessEventSnapshot.capture(skill, publisherId, + forceAutoPublish ? "system" : "user", "auto_publish"))); } // 13. Return identifiers for the created version diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java index c3e641ada..5fd06343e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -1,5 +1,7 @@ package com.iflytek.skillhub.domain.skill.service; +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; + import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -191,7 +193,8 @@ public class SkillReviewSubmitService { skillRepository.save(skill); eventPublisher.publishEvent(new SkillPublishedEvent( - skill.getId(), version.getId(), actorUserId)); + skill.getId(), version.getId(), actorUserId, + BusinessEventSnapshot.capture(skill, actorUserId, "private_confirm"))); } private void assertCanManageLifecycle(Skill skill, String actorUserId, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityService.java index 0775de3c0..a12694537 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.skill.SkillOwnerAccessPolicy; import com.iflytek.skillhub.domain.event.SkillVisibilityChangedEvent; +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; import com.iflytek.skillhub.domain.security.*; import com.iflytek.skillhub.domain.namespace.*; import com.iflytek.skillhub.domain.review.*; @@ -142,6 +143,7 @@ public class SkillVisibilityService { skill.setVisibility(target); skill.setUpdatedBy(actor); skills.save(skill); - events.publishEvent(new SkillVisibilityChangedEvent(skill.getId(), previous, target)); + events.publishEvent(new SkillVisibilityChangedEvent(skill.getId(), previous, target, + BusinessEventSnapshot.capture(skill, actor, "visibility"))); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 79c5dc6f2..26c070b5e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -593,6 +593,14 @@ class PromotionServiceTest { assertEquals(NEW_SKILL_ID, event.skillId()); assertEquals(NEW_VERSION_ID, event.versionId()); assertEquals(REVIEWER_ID, event.publisherId()); + assertEquals(TARGET_NAMESPACE_ID, event.snapshot().namespaceId()); + assertEquals(REVIEWER_ID, event.snapshot().actorId()); + assertEquals("promotion", event.snapshot().source()); + ArgumentCaptor approved = + ArgumentCaptor.forClass(com.iflytek.skillhub.domain.event.PromotionApprovedEvent.class); + verify(eventPublisher).publishEvent(approved.capture()); + assertEquals(5L, approved.getValue().snapshot().namespaceId()); + assertEquals(REVIEWER_ID, approved.getValue().snapshot().actorId()); // Verify targetSkillId updated on promotion request verify(promotionRequestRepository).save(approvedRequest); @@ -921,6 +929,7 @@ class PromotionServiceTest { void shouldRejectPromotionSuccessfully() { PromotionRequest pr = createPendingPromotion(); when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(createSourceSkill())); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion())) @@ -935,7 +944,11 @@ class PromotionServiceTest { assertEquals(Instant.now(CLOCK), result.getReviewedAt()); verify(promotionRequestRepository).updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Not ready", null, pr.getVersion()); - verify(eventPublisher, never()).publishEvent(any()); + var rejected = ArgumentCaptor.forClass(com.iflytek.skillhub.domain.event.PromotionRejectedEvent.class); + verify(eventPublisher).publishEvent(rejected.capture()); + assertEquals(SOURCE_SKILL_ID, rejected.getValue().skillId()); + assertEquals(5L, rejected.getValue().snapshot().namespaceId()); + assertEquals(REVIEWER_ID, rejected.getValue().snapshot().actorId()); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index bf7721578..105432ddf 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -392,6 +392,14 @@ class ReviewServiceTest { assertEquals(SKILL_ID, event.skillId()); assertEquals(SKILL_VERSION_ID, event.versionId()); assertEquals(REVIEWER_ID, event.publisherId()); + assertEquals(NAMESPACE_ID, event.snapshot().namespaceId()); + assertEquals(REVIEWER_ID, event.snapshot().actorId()); + assertEquals("review", event.snapshot().source()); + ArgumentCaptor approved = + ArgumentCaptor.forClass(com.iflytek.skillhub.domain.event.ReviewApprovedEvent.class); + verify(eventPublisher).publishEvent(approved.capture()); + assertEquals(REVIEWER_ID, approved.getValue().snapshot().actorId()); + assertNotNull(approved.getValue().snapshot().occurredAt()); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 7af64be84..eeb3cdd18 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -149,7 +149,10 @@ class SkillGovernanceServiceTest { assertThat(result.getStatus()).isEqualTo(SkillStatus.ARCHIVED); verify(auditLogService).record("owner", "ARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"cleanup\"}"); - verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class)); + var archived = org.mockito.ArgumentCaptor.forClass(SkillStatusChangedEvent.class); + verify(eventPublisher).publishEvent(archived.capture()); + assertThat(archived.getValue().snapshot().actorId()).isEqualTo("owner"); + assertThat(archived.getValue().snapshot().namespaceId()).isEqualTo(1L); } @Test @@ -164,7 +167,10 @@ class SkillGovernanceServiceTest { assertThat(result.getStatus()).isEqualTo(SkillStatus.ACTIVE); verify(auditLogService).record("owner", "UNARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", null); - verify(eventPublisher).publishEvent(any(SkillStatusChangedEvent.class)); + var restored = org.mockito.ArgumentCaptor.forClass(SkillStatusChangedEvent.class); + verify(eventPublisher).publishEvent(restored.capture()); + assertThat(restored.getValue().snapshot().actorId()).isEqualTo("owner"); + assertThat(restored.getValue().newStatus()).isEqualTo(SkillStatus.ACTIVE); } @Test @@ -211,7 +217,10 @@ class SkillGovernanceServiceTest { assertThat(result.getYankedBy()).isEqualTo("owner"); assertThat(result.isDownloadReady()).isFalse(); verify(auditLogService).record("owner", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); - verify(eventPublisher).publishEvent(any(com.iflytek.skillhub.domain.event.SkillVersionYankedEvent.class)); + var yanked = org.mockito.ArgumentCaptor.forClass(com.iflytek.skillhub.domain.event.SkillVersionYankedEvent.class); + verify(eventPublisher).publishEvent(yanked.capture()); + assertThat(yanked.getValue().snapshot().actorId()).isEqualTo("owner"); + assertThat(yanked.getValue().snapshot().namespaceId()).isEqualTo(1L); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index a47a48824..88fce3aee 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -804,7 +804,13 @@ class SkillPublishServiceTest { verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); verify(skillRepository).save(argThat(savedSkill -> savedSkill.getLatestVersionId() != null && savedSkill.getLatestVersionId().equals(10L))); - verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + var published = ArgumentCaptor.forClass(SkillPublishedEvent.class); + verify(eventPublisher).publishEvent(published.capture()); + assertEquals(1L, published.getValue().snapshot().namespaceId()); + assertEquals(publisherId, published.getValue().snapshot().actorId()); + assertEquals("user", published.getValue().snapshot().actorType()); + assertEquals("auto_publish", published.getValue().snapshot().source()); + assertNotNull(published.getValue().snapshot().occurredAt()); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java index 1bf52fa4f..e3bb670b0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java @@ -190,6 +190,12 @@ class SkillReviewSubmitServiceTest { assertEquals(skillId, event.skillId()); assertEquals(versionId, event.versionId()); assertEquals(userId, event.publisherId()); + assertEquals(namespaceId, event.snapshot().namespaceId()); + assertEquals(SkillVisibility.PRIVATE, event.snapshot().visibility()); + assertEquals(userId, event.snapshot().actorId()); + assertEquals("user", event.snapshot().actorType()); + assertEquals("private_confirm", event.snapshot().source()); + assertNotNull(event.snapshot().occurredAt()); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityServiceTest.java index ebd4acee2..81132617e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillVisibilityServiceTest.java @@ -51,7 +51,12 @@ class SkillVisibilityServiceTest { assertThat(skill.getLatestVersionId()).isEqualTo(3L); verify(versions, never()).save(any()); verify(reviews, never()).save(any()); - verify(events).publishEvent(any(SkillVisibilityChangedEvent.class)); + var event = org.mockito.ArgumentCaptor.forClass(SkillVisibilityChangedEvent.class); + verify(events).publishEvent(event.capture()); + assertThat(event.getValue().snapshot().namespaceId()).isEqualTo(2L); + assertThat(event.getValue().snapshot().visibility()).isEqualTo(SkillVisibility.PRIVATE); + assertThat(event.getValue().snapshot().actorId()).isEqualTo("owner"); + assertThat(event.getValue().previousVisibility()).isEqualTo(SkillVisibility.PUBLIC); } @Test @@ -62,9 +67,13 @@ class SkillVisibilityServiceTest { assertThat(task.getRequestedVisibility()).isEqualTo(SkillVisibility.PUBLIC); assertThat(task.getVisibilityVersionId()).isEqualTo(3L); assertThat(task.getSkillVersionId()).isNull(); + verifyNoInteractions(events); service.approve(task, "admin"); assertThat(skill.getVisibility()).isEqualTo(SkillVisibility.PUBLIC); verify(versions, never()).save(any()); + var event = org.mockito.ArgumentCaptor.forClass(SkillVisibilityChangedEvent.class); + verify(events).publishEvent(event.capture()); + assertThat(event.getValue().snapshot().actorId()).isEqualTo("admin"); } @Test From 244ae51ac5e61688aec2b237837cc5db407dc698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= <258399167+xiaoyaner0201@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:11:04 +0800 Subject: [PATCH 02/21] feat(observability): export opt-in business Logs over OTLP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com> --- .../business-event-logs/collector.yaml | 20 ++ .../examples/business-event-logs/compose.yaml | 14 + server/skillhub-app/pom.xml | 4 + .../business/BusinessEventListener.java | 43 +++ .../business/BusinessEventMapper.java | 84 +++++ .../business/BusinessLogsConfiguration.java | 49 +++ .../business/BusinessLogsMetrics.java | 42 +++ .../business/BusinessLogsPipeline.java | 116 +++++++ .../business/BusinessLogsProperties.java | 126 +++++++ .../observability/business/package-info.java | 2 + .../src/main/resources/application.yml | 16 + .../business/BusinessEventMapperTest.java | 80 +++++ .../business/BusinessLogsCollectorTest.java | 186 ++++++++++ .../BusinessLogsConfigurationTest.java | 81 +++++ .../business/BusinessLogsHttpTest.java | 89 +++++ .../BusinessLogsProducerPostgresTest.java | 322 ++++++++++++++++++ .../business/BusinessLogsTransactionTest.java | 234 +++++++++++++ 17 files changed, 1508 insertions(+) create mode 100644 docs/examples/business-event-logs/collector.yaml create mode 100644 docs/examples/business-event-logs/compose.yaml create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessEventListener.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessEventMapper.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsConfiguration.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsMetrics.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsPipeline.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsProperties.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/package-info.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessEventMapperTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsConfigurationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java diff --git a/docs/examples/business-event-logs/collector.yaml b/docs/examples/business-event-logs/collector.yaml new file mode 100644 index 000000000..1ce8d3783 --- /dev/null +++ b/docs/examples/business-event-logs/collector.yaml @@ -0,0 +1,20 @@ +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 +processors: + memory_limiter: + check_interval: 1s + limit_mib: 64 + spike_limit_mib: 16 +exporters: + file: + path: /business-logs.json + flush_interval: 100ms +service: + pipelines: + logs: + receivers: [otlp] + processors: [memory_limiter] + exporters: [file] diff --git a/docs/examples/business-event-logs/compose.yaml b/docs/examples/business-event-logs/compose.yaml new file mode 100644 index 000000000..e7428c8a5 --- /dev/null +++ b/docs/examples/business-event-logs/compose.yaml @@ -0,0 +1,14 @@ +name: skillhub-1009-business-logs +services: + collector: + image: otel/opentelemetry-collector-contrib:0.111.0 + container_name: skillhub-1009-collector + command: ["--config=/etc/otelcol-contrib/config.yaml"] + user: "0:0" + mem_limit: 128m + cpus: 0.5 + ports: + - "127.0.0.1:4318:4318" + volumes: + - ./collector.yaml:/etc/otelcol-contrib/config.yaml:ro + diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 454bb8aa8..8db07a16c 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -59,6 +59,10 @@ io.opentelemetry opentelemetry-exporter-otlp + + io.opentelemetry + opentelemetry-sdk-logs + org.apache.skywalking apm-toolkit-logback-1.x diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessEventListener.java new file mode 100644 index 000000000..b2a1d1226 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessEventListener.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.observability.business; + +import com.iflytek.skillhub.domain.event.SkillBusinessEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.context.Context; +import org.springframework.context.event.EventListener; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** Synchronous O(1) snapshot capture; only SDK admission runs after commit, never network IO. */ +public final class BusinessEventListener { + private final BusinessEventMapper mapper; + private final BusinessLogsPipeline pipeline; + private final BusinessLogsMetrics metrics; + + BusinessEventListener(BusinessEventMapper mapper, BusinessLogsPipeline pipeline, BusinessLogsMetrics metrics) { + this.mapper = mapper; + this.pipeline = pipeline; + this.metrics = metrics; + } + + @EventListener + public void onEvent(SkillBusinessEvent event) { + try { + mapper.map(event).ifPresent(record -> { + SpanContext span = Span.current().getSpanContext(); + // Retain only valid standard span identity, never baggage or arbitrary request/MDC data. + Context context = span.isValid() ? Context.root().with(Span.wrap(span)) : Context.root(); + if (TransactionSynchronizationManager.isActualTransactionActive() + && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { pipeline.emit(record, context); } + }); + } else { + pipeline.emit(record, context); + } + }); + } catch (RuntimeException ex) { + metrics.error(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessEventMapper.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessEventMapper.java new file mode 100644 index 000000000..50fb43ef9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessEventMapper.java @@ -0,0 +1,84 @@ +package com.iflytek.skillhub.observability.business; + +import com.iflytek.skillhub.domain.event.*; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** Sole data boundary. Never reflect/serialize an event or look up an entity here. */ +public final class BusinessEventMapper { + public static final Set EVENT_NAMES = Set.of("skill.publication.effective", "skill.download.prepared", + "skill.review.approved", "skill.review.rejected", "skill.promotion.approved", "skill.promotion.rejected", + "skill.status.changed", "skill.visibility.changed", "skill.version.yanked"); + private static final Set SOURCES = Set.of("auto_publish", "private_confirm", "review", "promotion", + "download", "governance", "visibility"); + private final Set events; + private final Set namespaces; + + BusinessEventMapper(BusinessLogsProperties properties) { + events = Set.copyOf(properties.getEventAllowlist()); + namespaces = Set.copyOf(properties.getNamespaceAllowlist()); + } + + public Optional map(SkillBusinessEvent event) { + BusinessEventSnapshot snapshot = event.snapshot(); + if (snapshot == null || snapshot.namespaceId() == null || snapshot.visibility() == null + || snapshot.occurredAt() == null || event.skillId() == null + || snapshot.namespaceId() <= 0 || event.skillId() <= 0 + || snapshot.source() == null || snapshot.actorType() == null + || !SOURCES.contains(snapshot.source()) + || !Set.of("user", "system").contains(snapshot.actorType()) + || (!namespaces.isEmpty() && !namespaces.contains(snapshot.namespaceId()))) { + return Optional.empty(); + } + AttributesBuilder attributes = Attributes.builder(); + String name; + switch (event) { + case SkillPublishedEvent e -> { name = "skill.publication.effective"; id(attributes, "version", e.versionId()); } + case SkillDownloadedEvent e -> { name = "skill.download.prepared"; id(attributes, "version", e.versionId()); } + case ReviewApprovedEvent e -> { name = "skill.review.approved"; id(attributes, "version", e.versionId()); id(attributes, "review", e.reviewId()); } + case ReviewRejectedEvent e -> { name = "skill.review.rejected"; id(attributes, "version", e.versionId()); id(attributes, "review", e.reviewId()); } + case PromotionApprovedEvent e -> { name = "skill.promotion.approved"; id(attributes, "promotion", e.promotionId()); } + case PromotionRejectedEvent e -> { name = "skill.promotion.rejected"; id(attributes, "promotion", e.promotionId()); } + case SkillStatusChangedEvent e -> { + if (e.oldStatus() == null || e.newStatus() == null || e.oldStatus() == e.newStatus()) { return Optional.empty(); } + name = "skill.status.changed"; + attributes.put("skillhub.status.old", e.oldStatus().name()).put("skillhub.status.new", e.newStatus().name()); + } + case SkillVisibilityChangedEvent e -> { + if (e.previousVisibility() == null || e.visibility() == null || e.previousVisibility() == e.visibility()) { return Optional.empty(); } + name = "skill.visibility.changed"; + attributes.put("skillhub.visibility.old", e.previousVisibility().name()).put("skillhub.visibility.new", e.visibility().name()); + } + case SkillVersionYankedEvent e -> { + if (!e.wasPublished()) { return Optional.empty(); } + name = "skill.version.yanked"; + id(attributes, "version", e.versionId()); + } + default -> { return Optional.empty(); } + } + if (!events.contains(name)) { return Optional.empty(); } + String uid = UUID.randomUUID().toString(); + attributes.put("skillhub.event.name", name).put("skillhub.skill.id", event.skillId()) + .put("skillhub.namespace.id", snapshot.namespaceId()).put("skillhub.visibility", snapshot.visibility().name()) + .put("skillhub.actor.type", snapshot.actorType()).put("skillhub.source", snapshot.source()) + .put("log.record.uid", uid); + if ("user".equals(snapshot.actorType()) && snapshot.actorId() != null && !snapshot.actorId().isBlank()) { + attributes.put("user.id", bounded(snapshot.actorId())); + } + return Optional.of(new MappedRecord(snapshot.occurredAt(), attributes.build(), uid)); + } + + static String bounded(String value) { + return value.length() <= 256 ? value : value.substring(0, 256); + } + + private static void id(AttributesBuilder attributes, String kind, Long value) { + if (value != null) { attributes.put("skillhub." + kind + ".id", value); } + } + + public record MappedRecord(Instant timestamp, Attributes attributes, String uid) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsConfiguration.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsConfiguration.java new file mode 100644 index 000000000..2e39c7f2c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsConfiguration.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.observability.business; + +import io.micrometer.core.instrument.MeterRegistry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.sdk.common.export.RetryPolicy; +import io.opentelemetry.sdk.resources.Resource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** Separate signal lifecycle; no global OTel registration or tracing beans. */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(BusinessLogsProperties.class) +@ConditionalOnProperty(prefix = "skillhub.observability.business-logs", name = "enabled", havingValue = "true") +public class BusinessLogsConfiguration { + @Bean + BusinessLogsMetrics businessLogsMetrics(MeterRegistry registry) { return new BusinessLogsMetrics(registry); } + + @Bean(destroyMethod = "close") + BusinessLogsPipeline businessLogsPipeline(BusinessLogsProperties properties, BusinessLogsMetrics metrics, + Environment environment) { + properties.validate(); + var builder = OtlpHttpLogRecordExporter.builder().setEndpoint(properties.getEndpoint()) + .setTimeout(properties.getTimeout()).setCompression(properties.getCompression()) + .setMeterProvider(MeterProvider.noop()); + properties.parsedHeaders().forEach(builder::addHeader); + if (properties.getRetryMaxAttempts() > 1) { + builder.setRetryPolicy(RetryPolicy.builder().setMaxAttempts(properties.getRetryMaxAttempts()) + .setInitialBackoff(properties.getRetryInitialBackoff()).setMaxBackoff(properties.getRetryMaxBackoff()) + .setBackoffMultiplier(2).build()); + } + Resource resource = Resource.create(Attributes.builder() + .put("service.name", BusinessEventMapper.bounded(environment.getProperty("spring.application.name", "skillhub"))) + .put("service.version", BusinessEventMapper.bounded(environment.getProperty("skillhub.observability.service-version", "unknown"))) + .put("deployment.environment", BusinessEventMapper.bounded(environment.getProperty("skillhub.observability.service-environment", "local"))) + .build()); + return new BusinessLogsPipeline(properties, resource, builder.build(), metrics); + } + + @Bean + BusinessEventListener businessEventListener(BusinessLogsProperties properties, BusinessLogsPipeline pipeline, + BusinessLogsMetrics metrics) { + return new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsMetrics.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsMetrics.java new file mode 100644 index 000000000..bb9bb3090 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsMetrics.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.observability.business; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Fixed-cardinality counters; diagnostics contain no events, exception text, URLs or credentials. */ +final class BusinessLogsMetrics { + private static final Logger log = LoggerFactory.getLogger(BusinessLogsMetrics.class); + private final AtomicLong lastWarning = new AtomicLong(); + final Counter enqueued; + final Counter queueFull; + final Counter closed; + final Counter errors; + final Counter exportFailures; + final Counter exported; + + BusinessLogsMetrics(MeterRegistry registry) { + enqueued = registry.counter("skillhub.business.logs.enqueued"); + queueFull = registry.counter("skillhub.business.logs.dropped", "reason", "queue_full"); + closed = registry.counter("skillhub.business.logs.dropped", "reason", "shutdown"); + errors = registry.counter("skillhub.business.logs.errors"); + exportFailures = registry.counter("skillhub.business.logs.export.failures"); + exported = registry.counter("skillhub.business.logs.exported"); + } + + void error() { + errors.increment(); + warn(); + } + + void warn() { + long now = System.nanoTime(); + long last = lastWarning.get(); + if ((last == 0 || now - last >= java.util.concurrent.TimeUnit.MINUTES.toNanos(1)) + && lastWarning.compareAndSet(last, now)) { + log.warn("Business Logs telemetry failed; inspect skillhub.business.logs metrics and receiver health"); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsPipeline.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsPipeline.java new file mode 100644 index 000000000..5851cb128 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsPipeline.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.observability.business; + +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.LogLimits; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.ReadWriteLogRecord; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.resources.Resource; +import java.time.Duration; +import java.util.Collection; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Dedicated SDK pipeline. Admission permits bound queued + in-flight records together, conservatively + * below the SDK queue capacity, providing exact admission metrics without accessing SDK internals. + * All network batching/retry remains in the standard SDK/exporter. + */ +public final class BusinessLogsPipeline implements AutoCloseable { + private final SdkLoggerProvider provider; + private final Logger logger; + private final LogRecordExporter exporter; + private final BusinessLogsMetrics metrics; + private final Duration shutdownTimeout; + private final AtomicBoolean accepting = new AtomicBoolean(true); + + BusinessLogsPipeline(BusinessLogsProperties properties, Resource resource, + LogRecordExporter exporter, BusinessLogsMetrics metrics) { + this.exporter = exporter; + this.metrics = metrics; + this.shutdownTimeout = properties.getShutdownTimeout(); + Semaphore capacity = new Semaphore(properties.getMaxQueueSize()); + LogRecordExporter measured = new LogRecordExporter() { + @Override + public CompletableResultCode export(Collection records) { + int count = records.size(); + try { + CompletableResultCode result = exporter.export(records); + result.whenComplete(() -> { + capacity.release(count); + if (result.isSuccess()) { metrics.exported.increment(count); } + else { metrics.exportFailures.increment(); metrics.warn(); } + }); + return result; + } catch (RuntimeException ex) { + capacity.release(count); + metrics.exportFailures.increment(); + metrics.warn(); + return CompletableResultCode.ofFailure(); + } + } + @Override public CompletableResultCode flush() { return exporter.flush(); } + @Override public CompletableResultCode shutdown() { return exporter.shutdown(); } + }; + BatchLogRecordProcessor batch = BatchLogRecordProcessor.builder(measured) + .setMeterProvider(MeterProvider.noop()) + .setMaxQueueSize(properties.getMaxQueueSize()) + .setMaxExportBatchSize(properties.getMaxExportBatchSize()) + .setScheduleDelay(properties.getScheduleDelay()) + .setExporterTimeout(properties.exportBudget()).build(); + LogRecordProcessor admission = new LogRecordProcessor() { + @Override + public void onEmit(Context context, ReadWriteLogRecord record) { + if (!accepting.get()) { metrics.closed.increment(); return; } + if (!capacity.tryAcquire()) { metrics.queueFull.increment(); return; } + try { + batch.onEmit(context, record); + metrics.enqueued.increment(); + } catch (RuntimeException ex) { + capacity.release(); + metrics.error(); + } + } + @Override public CompletableResultCode forceFlush() { return batch.forceFlush(); } + @Override public CompletableResultCode shutdown() { return batch.shutdown(); } + }; + provider = SdkLoggerProvider.builder().setResource(resource) + .setLogLimits(() -> LogLimits.builder().setMaxNumberOfAttributes(16) + .setMaxAttributeValueLength(256).build()) + .addLogRecordProcessor(admission).build(); + logger = provider.loggerBuilder("com.iflytek.skillhub.business").setInstrumentationVersion("1").build(); + } + + void emit(BusinessEventMapper.MappedRecord record, Context context) { + try { + if (!accepting.get()) { metrics.closed.increment(); return; } + logger.logRecordBuilder().setTimestamp(record.timestamp()).setContext(context) + .setAllAttributes(record.attributes()).emit(); + } catch (RuntimeException ex) { + metrics.error(); + } + } + + CompletableResultCode forceFlush() { return provider.forceFlush(); } + + @Override + public void close() { + if (!accepting.getAndSet(false)) { return; } + try { + CompletableResultCode shutdown = provider.shutdown(); + shutdown.join(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS); + if (!shutdown.isDone()) { exporter.shutdown(); } + } catch (RuntimeException ex) { + metrics.error(); + // Also cancel network work if shutdown itself unexpectedly fails. + try { exporter.shutdown(); } catch (RuntimeException shutdownException) { metrics.error(); } + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsProperties.java new file mode 100644 index 000000000..6ee7368a5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsProperties.java @@ -0,0 +1,126 @@ +package com.iflytek.skillhub.observability.business; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Startup-only operator settings. Deliberately no toString: headers contain secrets. */ +@ConfigurationProperties("skillhub.observability.business-logs") +public class BusinessLogsProperties { + private boolean enabled; + private String endpoint = ""; + private String headers = ""; + private Duration timeout = Duration.ofSeconds(3); + private String compression = "gzip"; + private int maxQueueSize = 2048; + private int maxExportBatchSize = 256; + private Duration scheduleDelay = Duration.ofSeconds(1); + private Duration shutdownTimeout = Duration.ofSeconds(5); + private int retryMaxAttempts = 3; + private Duration retryInitialBackoff = Duration.ofMillis(200); + private Duration retryMaxBackoff = Duration.ofSeconds(1); + private Set eventAllowlist = BusinessEventMapper.EVENT_NAMES; + private Set namespaceAllowlist = Set.of(); + + public void validate() { + if (!enabled) { return; } + try { + URI uri = URI.create(endpoint); + String host = uri.getHost(); + if (!("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) + || host == null || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null + || uri.getPath() == null || uri.getPath().isBlank() || "/".equals(uri.getPath()) + || host.startsWith("169.254.") || host.equals("100.100.100.200") + || host.equalsIgnoreCase("metadata.google.internal") || host.equalsIgnoreCase("metadata") + || host.equalsIgnoreCase("[fd00:ec2::254]") || host.equals("0.0.0.0")) { + throw new IllegalArgumentException(); + } + } catch (RuntimeException ex) { + throw new IllegalStateException("skillhub.observability.business-logs.endpoint requires an explicit HTTP(S) Logs URL without credentials, query, fragment or metadata target"); + } + require(maxQueueSize >= 1 && maxQueueSize <= 65536, "max-queue-size must be 1..65536"); + require(maxExportBatchSize >= 1 && maxExportBatchSize <= Math.min(4096, maxQueueSize), + "max-export-batch-size must be 1..min(4096,max-queue-size)"); + duration(timeout, 100, 30000, "timeout"); + duration(scheduleDelay, 10, 60000, "schedule-delay"); + duration(shutdownTimeout, 100, 30000, "shutdown-timeout"); + duration(retryInitialBackoff, 1, 5000, "retry-initial-backoff"); + duration(retryMaxBackoff, 1, 10000, "retry-max-backoff"); + require(retryMaxAttempts >= 1 && retryMaxAttempts <= 5, "retry-max-attempts must be 1..5"); + require(retryInitialBackoff.compareTo(retryMaxBackoff) <= 0, "retry backoff bounds are inconsistent"); + require(Set.of("none", "gzip").contains(compression), "compression must be none or gzip"); + require(eventAllowlist != null && BusinessEventMapper.EVENT_NAMES.containsAll(eventAllowlist), "event-allowlist contains unsupported event names"); + require(namespaceAllowlist != null && namespaceAllowlist.size() <= 1000 + && namespaceAllowlist.stream().allMatch(id -> id != null && id > 0), "namespace-allowlist requires at most 1000 positive IDs"); + parsedHeaders(); + } + + Map parsedHeaders() { + try { + if (headers == null || headers.isBlank()) { return Map.of(); } + if (headers.length() > 8192) { throw new IllegalArgumentException(); } + Map result = new LinkedHashMap<>(); + String[] pairs = headers.split(",", -1); + if (pairs.length > 16) { throw new IllegalArgumentException(); } + for (String header : pairs) { + String[] pair = header.split("=", 2); + if (pair.length != 2 || result.size() >= 16) { throw new IllegalArgumentException(); } + String name = URLDecoder.decode(pair[0].trim(), StandardCharsets.UTF_8); + String value = URLDecoder.decode(pair[1].trim(), StandardCharsets.UTF_8); + if (!name.matches("[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}") + || value.chars().anyMatch(c -> c < 32 || c > 126)) { throw new IllegalArgumentException(); } + result.put(name, value); + } + return Map.copyOf(result); + } catch (RuntimeException ex) { + throw new IllegalStateException("business-logs.headers must contain at most 16 URL-encoded name=value pairs (8192 characters), without control characters"); + } + } + + private static void duration(Duration value, long min, long max, String key) { + require(value != null && value.compareTo(Duration.ofMillis(min)) >= 0 + && value.compareTo(Duration.ofMillis(max)) <= 0, key + " is outside documented bounds"); + } + + private static void require(boolean condition, String message) { + if (!condition) { throw new IllegalStateException("skillhub.observability.business-logs: " + message); } + } + + Duration exportBudget() { + return timeout.plus(retryMaxBackoff).multipliedBy(retryMaxAttempts).plusSeconds(1); + } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public String getEndpoint() { return endpoint; } + public void setEndpoint(String endpoint) { this.endpoint = endpoint; } + public String getHeaders() { return headers; } + public void setHeaders(String headers) { this.headers = headers; } + public Duration getTimeout() { return timeout; } + public void setTimeout(Duration timeout) { this.timeout = timeout; } + public String getCompression() { return compression; } + public void setCompression(String compression) { this.compression = compression; } + public int getMaxQueueSize() { return maxQueueSize; } + public void setMaxQueueSize(int maxQueueSize) { this.maxQueueSize = maxQueueSize; } + public int getMaxExportBatchSize() { return maxExportBatchSize; } + public void setMaxExportBatchSize(int maxExportBatchSize) { this.maxExportBatchSize = maxExportBatchSize; } + public Duration getScheduleDelay() { return scheduleDelay; } + public void setScheduleDelay(Duration scheduleDelay) { this.scheduleDelay = scheduleDelay; } + public Duration getShutdownTimeout() { return shutdownTimeout; } + public void setShutdownTimeout(Duration shutdownTimeout) { this.shutdownTimeout = shutdownTimeout; } + public int getRetryMaxAttempts() { return retryMaxAttempts; } + public void setRetryMaxAttempts(int retryMaxAttempts) { this.retryMaxAttempts = retryMaxAttempts; } + public Duration getRetryInitialBackoff() { return retryInitialBackoff; } + public void setRetryInitialBackoff(Duration retryInitialBackoff) { this.retryInitialBackoff = retryInitialBackoff; } + public Duration getRetryMaxBackoff() { return retryMaxBackoff; } + public void setRetryMaxBackoff(Duration retryMaxBackoff) { this.retryMaxBackoff = retryMaxBackoff; } + public Set getEventAllowlist() { return eventAllowlist; } + public void setEventAllowlist(Set eventAllowlist) { this.eventAllowlist = eventAllowlist; } + public Set getNamespaceAllowlist() { return namespaceAllowlist; } + public void setNamespaceAllowlist(Set namespaceAllowlist) { this.namespaceAllowlist = namespaceAllowlist; } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/package-info.java new file mode 100644 index 000000000..a81685cb1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/package-info.java @@ -0,0 +1,2 @@ +/** Opt-in, independent, lossy business analytics adapter; never an audit or application-log bridge. */ +package com.iflytek.skillhub.observability.business; diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 3b2f58f82..c54db5379 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -170,6 +170,22 @@ skillhub: confirmation-enabled: ${SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED:false} observability: tracing-mode: ${SKILLHUB_TRACING_MODE:none} + business-logs: + enabled: ${SKILLHUB_BUSINESS_LOGS_ENABLED:false} + # Logs-specific URL is used as-is; include /v1/logs (not /v1/traces). + endpoint: ${SKILLHUB_BUSINESS_LOGS_ENDPOINT:} + headers: ${SKILLHUB_BUSINESS_LOGS_HEADERS:} + timeout: ${SKILLHUB_BUSINESS_LOGS_TIMEOUT:3s} + compression: ${SKILLHUB_BUSINESS_LOGS_COMPRESSION:gzip} + max-queue-size: ${SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE:2048} + max-export-batch-size: ${SKILLHUB_BUSINESS_LOGS_MAX_EXPORT_BATCH_SIZE:256} + schedule-delay: ${SKILLHUB_BUSINESS_LOGS_SCHEDULE_DELAY:1s} + shutdown-timeout: ${SKILLHUB_BUSINESS_LOGS_SHUTDOWN_TIMEOUT:5s} + retry-max-attempts: ${SKILLHUB_BUSINESS_LOGS_RETRY_MAX_ATTEMPTS:3} + retry-initial-backoff: ${SKILLHUB_BUSINESS_LOGS_RETRY_INITIAL_BACKOFF:200ms} + retry-max-backoff: ${SKILLHUB_BUSINESS_LOGS_RETRY_MAX_BACKOFF:1s} + event-allowlist: ${SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST:skill.publication.effective,skill.download.prepared,skill.review.approved,skill.review.rejected,skill.promotion.approved,skill.promotion.rejected,skill.status.changed,skill.visibility.changed,skill.version.yanked} + namespace-allowlist: ${SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST:} log-format: ${SKILLHUB_LOG_FORMAT:text} log-async-queue-size: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:1024} service-version: ${SKILLHUB_SERVICE_VERSION:unknown} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessEventMapperTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessEventMapperTest.java new file mode 100644 index 000000000..05e0a31d4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessEventMapperTest.java @@ -0,0 +1,80 @@ +package com.iflytek.skillhub.observability.business; + +import com.iflytek.skillhub.domain.event.*; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import io.opentelemetry.api.common.AttributeKey; +import java.time.Instant; +import java.util.Set; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BusinessEventMapperTest { + private final BusinessEventMapper mapper = new BusinessEventMapper(new BusinessLogsProperties()); + private final BusinessEventSnapshot snapshot = new BusinessEventSnapshot( + 7L, SkillVisibility.PRIVATE, "reviewer-opaque", "user", "review", Instant.parse("2026-10-01T00:00:00Z")); + + @Test + void rejectionExportsOnlyExplicitAllowlistNeverReasonOrSubmitter() { + var record = mapper.map(new ReviewRejectedEvent(3L, 1L, 2L, "reviewer-opaque", "secret-email", + "token=secret signed-url IP UA content", snapshot)).orElseThrow(); + assertThat(record.timestamp()).isEqualTo(snapshot.occurredAt()); + assertThat(record.attributes().asMap().keySet()).extracting(AttributeKey::getKey).containsExactlyInAnyOrder( + "skillhub.event.name", "skillhub.skill.id", "skillhub.version.id", "skillhub.namespace.id", + "skillhub.visibility", "skillhub.source", "skillhub.actor.type", "user.id", "log.record.uid", + "skillhub.review.id"); + assertThat(record.attributes().toString()).doesNotContain("secret", "signed-url", "content", "submitter"); + assertThat(record.attributes().get(AttributeKey.stringKey("user.id"))).isEqualTo("reviewer-opaque"); + } + + @Test + void anonymousHasNoUserIdAndMappingHasStableUid() { + var anonymous = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, null, "user", "download", snapshot.occurredAt()); + var record = mapper.map(new SkillDownloadedEvent(1L, 2L, anonymous)).orElseThrow(); + assertThat(record.attributes().get(AttributeKey.stringKey("user.id"))).isNull(); + assertThat(record.attributes().get(AttributeKey.stringKey("skillhub.actor.type"))).isEqualTo("user"); + assertThat(record.attributes().get(AttributeKey.stringKey("log.record.uid"))) + .isEqualTo(record.uid()).matches("[0-9a-f-]{36}"); + } + + @Test + void namespaceAndEventAllowlistFailClosedAndMissingSnapshotIsNotEnrichedFromDatabase() { + var properties = new BusinessLogsProperties(); + properties.setNamespaceAllowlist(Set.of(8L)); + assertThat(new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "actor", snapshot))).isEmpty(); + properties.setNamespaceAllowlist(Set.of()); + properties.setEventAllowlist(Set.of("skill.download.prepared")); + assertThat(new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "actor", snapshot))).isEmpty(); + assertThat(mapper.map(new SkillPublishedEvent(1L, 2L, "actor"))).isEmpty(); + } + + @Test + void userValuesAreLengthBoundedAndSystemDoesNotExportServiceAccountAsHuman() { + var system = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, "x".repeat(1000), "system", "auto_publish", snapshot.occurredAt()); + var record = mapper.map(new SkillPublishedEvent(1L, 2L, "ignored", system)).orElseThrow(); + assertThat(record.attributes().get(AttributeKey.stringKey("user.id"))).isNull(); + var human = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, "x".repeat(1000), "user", "review", snapshot.occurredAt()); + assertThat(mapper.map(new SkillPublishedEvent(1L, 2L, "ignored", human)).orElseThrow() + .attributes().get(AttributeKey.stringKey("user.id"))).hasSize(256); + } + + @Test + void unknownEventAndNullContextFieldsAreNeverSerializedOrEnriched() { + record SensitiveEvent(Long skillId, BusinessEventSnapshot snapshot, String password) implements SkillBusinessEvent {} + assertThat(mapper.map(new SensitiveEvent(1L, snapshot, "must-never-appear"))).isEmpty(); + var missingSource = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, "actor", "user", null, snapshot.occurredAt()); + assertThat(mapper.map(new SkillPublishedEvent(1L, 2L, "actor", missingSource))).isEmpty(); + assertThat(mapper.map(new SkillVersionYankedEvent(1L, 2L, "actor", false, snapshot))).isEmpty(); + assertThat(mapper.map(new SkillStatusChangedEvent(1L, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE, + com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE, snapshot))).isEmpty(); + } + + @Test + void reservedBuiltInPublisherIsSystemEvenThroughOrdinaryPrivilegedPublishPath() { + var skill = new com.iflytek.skillhub.domain.skill.Skill(7L, "not-exported", "builtin-skill-publisher", SkillVisibility.PUBLIC); + var captured = BusinessEventSnapshot.capture(skill, "builtin-skill-publisher", "user", "auto_publish"); + assertThat(captured.actorType()).isEqualTo("system"); + var record = mapper.map(new SkillPublishedEvent(1L, 2L, "builtin-skill-publisher", captured)).orElseThrow(); + assertThat(record.attributes().get(AttributeKey.stringKey("user.id"))).isNull(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java new file mode 100644 index 000000000..d17bdfeec --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java @@ -0,0 +1,186 @@ +package com.iflytek.skillhub.observability.business; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.*; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.observability.tracing.SkillHubTracingConfiguration; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.opentelemetry.api.common.AttributeKey; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Actual production configuration sends protobuf over HTTP to a pinned real Collector/file sink. */ +@Testcontainers +class BusinessLogsCollectorTest { + @Container + static final GenericContainer COLLECTOR = new GenericContainer<>("otel/opentelemetry-collector-contrib:0.111.0") + .withExposedPorts(4318) + .withCopyFileToContainer(MountableFile.forHostPath(Path.of("../../docs/examples/business-event-logs/collector.yaml").toAbsolutePath()), + "/etc/otelcol-contrib/config.yaml") + .withCreateContainerCmdModifier(cmd -> { + cmd.withName("skillhub-1009-collector-integration"); + cmd.withUser("0:0"); + cmd.getHostConfig().withMemory(128L * 1024 * 1024).withNanoCPUs(500_000_000L); + }) + .waitingFor(Wait.forLogMessage(".*Everything is ready.*", 1)); + + @Configuration(proxyBeanMethods = false) + @EnableAutoConfiguration + @Import({SkillHubTracingConfiguration.class, BusinessLogsConfiguration.class}) + static class Application {} + + @Test + void realCollectorReceivesNineFactsInAllThreeTraceModesAndResourceLimitsAreExplicit() throws Exception { + var hostConfig = COLLECTOR.getCurrentContainerInfo().getHostConfig(); + assertThat(hostConfig.getMemory()).isEqualTo(128L * 1024 * 1024); + assertThat(hostConfig.getNanoCPUs()).isEqualTo(500_000_000L); + var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, "reviewer", "user", "review", Instant.now()); + var events = List.of( + new SkillPublishedEvent(1L, 2L, "reviewer", snapshot), + new SkillDownloadedEvent(1L, 2L, new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, null, "user", "download", snapshot.occurredAt())), + new ReviewApprovedEvent(3L, 1L, 2L, "reviewer", "private-owner", snapshot), + new ReviewRejectedEvent(3L, 1L, 2L, "reviewer", "private-owner", "SECRET-CONTENT-URL-TOKEN", snapshot), + new PromotionApprovedEvent(4L, 1L, "reviewer", "private-owner", snapshot), + new PromotionRejectedEvent(4L, 1L, "reviewer", "private-owner", "SECRET-CONTENT-URL-TOKEN", snapshot), + new SkillStatusChangedEvent(1L, SkillStatus.ACTIVE, SkillStatus.ARCHIVED, snapshot), + new SkillVisibilityChangedEvent(1L, SkillVisibility.PUBLIC, SkillVisibility.PRIVATE, snapshot), + new SkillVersionYankedEvent(1L, 2L, "reviewer", true, snapshot)); + for (String mode : List.of("none", "otel-sdk", "external-agent")) { + new ApplicationContextRunner().withUserConfiguration(Application.class) + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withPropertyValues("spring.flyway.enabled=false", "spring.jpa.hibernate.ddl-auto=none", + "skillhub.observability.tracing-mode=" + mode, + "management.tracing.sampling.probability=0", + "skillhub.observability.service-version=collector-test", + "skillhub.observability.service-environment=test", + "skillhub.observability.business-logs.enabled=true", + "skillhub.observability.business-logs.endpoint=http://" + COLLECTOR.getHost() + ":" + COLLECTOR.getMappedPort(4318) + "/v1/logs") + .run(context -> { + assertThat(context).hasNotFailed(); + events.forEach(context::publishEvent); + var result = context.getBean(BusinessLogsPipeline.class).forceFlush(); + result.join(10, TimeUnit.SECONDS); + assertThat(result.isSuccess()).isTrue(); + var registry = context.getBean(SimpleMeterRegistry.class); + assertThat(registry.get("skillhub.business.logs.exported").counter().count()).isEqualTo(9); + }); + } + String raw = awaitFile(27); + assertThat(raw).doesNotContain("SECRET-CONTENT-URL-TOKEN", "private-owner", "request.id", "authorization"); + List records = records(raw); + assertThat(records).hasSize(27); + var names = new HashSet(); + var uids = new HashSet(); + for (JsonNode record : records) { + var attrs = attributes(record); + names.add(attrs.get("skillhub.event.name").asText()); + assertThat(uids.add(attrs.get("log.record.uid").asText())).isTrue(); + assertThat(attrs.size()).isLessThanOrEqualTo(16); + assertThat(attrs.get("skillhub.namespace.id").asText()).isEqualTo("7"); + if (attrs.get("skillhub.event.name").asText().equals("skill.download.prepared")) { assertThat(attrs).doesNotContainKey("user.id"); } + } + assertThat(names).containsExactlyInAnyOrderElementsOf(BusinessEventMapper.EVENT_NAMES); + String evidence = System.getProperty("skillhub.test.evidence-dir"); + if (evidence != null) { Files.writeString(Path.of(evidence, "collector-received.jsonl"), raw); } + System.out.println("BUSINESS_LOGS real Collector 0.111.0: queried 27 records / 9 event names / 27 unique uids; modes none,otel-sdk(sample=0),external-agent; 128 MiB, 0.5 CPU"); + String endpoint = "http://" + COLLECTOR.getHost() + ":" + COLLECTOR.getMappedPort(4318) + "/v1/logs"; + COLLECTOR.getDockerClient().pauseContainerCmd(COLLECTOR.getContainerId()).exec(); + try { + outageRunner(endpoint).run(context -> { + assertThat(context).hasNotFailed(); + long start = System.nanoTime(); + for (int i = 0; i < 1000; i++) { context.publishEvent(events.getFirst()); } + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + assertThat(elapsed).isLessThan(1000); + context.getBean(BusinessLogsPipeline.class).forceFlush().join(5, TimeUnit.SECONDS); + var registry = context.getBean(SimpleMeterRegistry.class); + double dropped = registry.get("skillhub.business.logs.dropped").tag("reason", "queue_full").counter().count(); + assertThat(dropped).isGreaterThanOrEqualTo(998); + assertThat(registry.get("skillhub.business.logs.export.failures").counter().count()).isPositive(); + System.out.printf("BUSINESS_LOGS paused real Collector: 1000 caller events in %d ms, drops=%.0f; timeout failures counted%n", elapsed, dropped); + }); + } finally { COLLECTOR.getDockerClient().unpauseContainerCmd(COLLECTOR.getContainerId()).exec(); } + COLLECTOR.stop(); + outageRunner(endpoint).run(context -> { + assertThat(context).hasNotFailed(); + long start = System.nanoTime(); + context.publishEvent(events.getFirst()); + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + assertThat(elapsed).isLessThan(1000); + context.getBean(BusinessLogsPipeline.class).forceFlush().join(5, TimeUnit.SECONDS); + assertThat(context.getBean(SimpleMeterRegistry.class).get("skillhub.business.logs.export.failures").counter().count()).isEqualTo(1); + System.out.printf("BUSINESS_LOGS stopped real Collector: caller=%d ms; failure counted, no business exception%n", elapsed); + }); + } + + private static ApplicationContextRunner outageRunner(String endpoint) { + return new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class) + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withPropertyValues("skillhub.observability.business-logs.enabled=true", + "skillhub.observability.business-logs.endpoint=" + endpoint, + "skillhub.observability.business-logs.max-queue-size=2", + "skillhub.observability.business-logs.max-export-batch-size=1", + "skillhub.observability.business-logs.timeout=500ms", + "skillhub.observability.business-logs.retry-max-attempts=1"); + } + + private static String awaitFile(int expected) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + String raw = ""; + Exception lastError = null; + do { + try { + raw = COLLECTOR.copyFileFromContainer("/business-logs.json", stream -> new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + if (records(raw).size() >= expected) { return raw; } + } catch (Exception ex) { lastError = ex; } + TimeUnit.MILLISECONDS.sleep(100); + } while (System.nanoTime() < deadline); + String evidence = System.getProperty("skillhub.test.evidence-dir"); + if (evidence != null) { + Files.writeString(Path.of(evidence, "collector-debug.jsonl"), raw); + Files.writeString(Path.of(evidence, "collector-debug.log"), COLLECTOR.getLogs()); + } + throw new AssertionError("Collector file did not contain " + expected + " records; raw characters=" + raw.length(), lastError); + } + + static List records(String raw) throws Exception { + List result = new ArrayList<>(); + var mapper = new ObjectMapper(); + for (String line : raw.lines().filter(s -> !s.isBlank()).toList()) { + JsonNode root = mapper.readTree(line); + for (JsonNode resource : root.path("resourceLogs")) { + for (JsonNode scope : resource.path("scopeLogs")) { + for (JsonNode record : scope.path("logRecords")) { result.add(record); } + } + } + } + return result; + } + + static Map attributes(JsonNode record) { + var result = new java.util.HashMap(); + for (JsonNode attr : record.path("attributes")) { result.put(attr.path("key").asText(), attr.path("value").elements().next()); } + return result; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsConfigurationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsConfigurationTest.java new file mode 100644 index 000000000..e1330f0dd --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsConfigurationTest.java @@ -0,0 +1,81 @@ +package com.iflytek.skillhub.observability.business; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BusinessLogsConfigurationTest { + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(BusinessLogsConfiguration.class) + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new); + + @Test + void defaultOffCreatesNoProviderPipelineOrListener() { + runner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(BusinessLogsPipeline.class); + assertThat(context).doesNotHaveBean(BusinessEventListener.class); + assertThat(context).doesNotHaveBean(io.opentelemetry.sdk.logs.SdkLoggerProvider.class); + }); + } + + @Test + void enabledRequiresExplicitEndpoint() { + runner.withPropertyValues("skillhub.observability.business-logs.enabled=true") + .run(context -> assertThat(context).hasFailed()); + } + + @ParameterizedTest + @ValueSource(strings = {"http://169.254.169.254/v1/logs", "http://metadata.google.internal/v1/logs", + "http://user:password@localhost/v1/logs", "http://localhost/v1/logs?secret=x", "file:///v1/logs"}) + void invalidDestinationsFailWithoutEchoingInput(String endpoint) { + var properties = new BusinessLogsProperties(); + properties.setEnabled(true); + properties.setEndpoint(endpoint); + assertThatThrownBy(properties::validate).isInstanceOf(IllegalStateException.class) + .hasMessageNotContaining(endpoint).hasMessageNotContaining("password").hasMessageNotContaining("secret=x"); + } + + @Test + void budgetsAndEventNamesAreValidated() { + var properties = new BusinessLogsProperties(); + properties.setEnabled(true); + properties.setEndpoint("http://localhost:4318/v1/logs"); + properties.setMaxQueueSize(1); + properties.setMaxExportBatchSize(2); + assertThatThrownBy(properties::validate).isInstanceOf(IllegalStateException.class); + properties.setMaxExportBatchSize(1); + properties.setEventAllowlist(java.util.Set.of("not-a-business-event")); + assertThatThrownBy(properties::validate).isInstanceOf(IllegalStateException.class); + } + + @Test + void headerEncodingIsSecretFriendlyAndErrorsNeverEchoSecretInput() { + var properties = new BusinessLogsProperties(); + properties.setHeaders("Authorization=Bearer%20fake%2Bsecret,x-tenant=example"); + assertThat(properties.parsedHeaders()).containsEntry("Authorization", "Bearer fake+secret"); + properties.setHeaders("Authorization=fake-secret%0Ainjected"); + assertThatThrownBy(properties::parsedHeaders).isInstanceOf(IllegalStateException.class) + .hasMessageNotContaining("fake-secret").hasMessageNotContaining("injected"); + properties.setHeaders(String.join(",", java.util.Collections.nCopies(17, "same=value"))); + assertThatThrownBy(properties::parsedHeaders).isInstanceOf(IllegalStateException.class); + } + + @Test + void emptyNamespaceFilterAndExplicitEmptyEventFilterBindWithoutDefaultDestination() { + runner.withPropertyValues("skillhub.observability.business-logs.enabled=true", + "skillhub.observability.business-logs.endpoint=http://127.0.0.1:4318/v1/logs", + "skillhub.observability.business-logs.namespace-allowlist=", + "skillhub.observability.business-logs.event-allowlist=") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(BusinessLogsProperties.class).getNamespaceAllowlist()).isEmpty(); + assertThat(context.getBean(BusinessLogsProperties.class).getEventAllowlist()).isEmpty(); + }); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java new file mode 100644 index 000000000..73749ae00 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java @@ -0,0 +1,89 @@ +package com.iflytek.skillhub.observability.business; + +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.sun.net.httpserver.HttpServer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.opentelemetry.context.Context; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.sdk.common.export.RetryPolicy; +import io.opentelemetry.sdk.resources.Resource; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BusinessLogsHttpTest { + @Test + void boundedRetryReusesIdenticalUidAndPayloadAndAuthenticationIsTransportOnly() throws Exception { + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + List bodies = new CopyOnWriteArrayList<>(); + List auth = new CopyOnWriteArrayList<>(); + var requests = new AtomicInteger(); + server.createContext("/v1/logs", exchange -> { + bodies.add(exchange.getRequestBody().readAllBytes()); + auth.add(exchange.getRequestHeaders().getFirst("Authorization")); + exchange.sendResponseHeaders(requests.incrementAndGet() == 1 ? 503 : 200, -1); + exchange.close(); + }); + server.start(); + var properties = new BusinessLogsProperties(); + properties.setCompression("none"); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var exporter = OtlpHttpLogRecordExporter.builder() + .setEndpoint("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/logs") + .setTimeout(Duration.ofMillis(500)).addHeader("Authorization", "Bearer fake-deployment-secret") + .setRetryPolicy(RetryPolicy.builder().setMaxAttempts(2).setInitialBackoff(Duration.ofMillis(10)) + .setMaxBackoff(Duration.ofMillis(10)).build()).build(); + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) { + var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now()); + var record = new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "reviewer", snapshot)).orElseThrow(); + pipeline.emit(record, Context.root()); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(requests.get()).isEqualTo(2); + assertThat(bodies.get(0)).isEqualTo(bodies.get(1)); + String payload = new String(bodies.getFirst(), java.nio.charset.StandardCharsets.ISO_8859_1); + assertThat(payload).contains(record.uid()).doesNotContain("fake-deployment-secret", "Authorization"); + assertThat(auth).containsExactly("Bearer fake-deployment-secret", "Bearer fake-deployment-secret"); + assertThat(metrics.exported.count()).isEqualTo(1); + assertThat(metrics.exportFailures.count()).isZero(); + System.out.println("BUSINESS_LOGS retry: 503 then 200, 2 identical payloads, 1 stable uid; transport-only auth"); + } finally { server.stop(0); } + } + + @Test + void twoHundredPartialSuccessIsCountedAsSdkSuccessWithoutWholeBatchReplay() throws Exception { + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + var requests = new AtomicInteger(); + server.createContext("/v1/logs", exchange -> { + exchange.getRequestBody().readAllBytes(); + requests.incrementAndGet(); + // ExportLogsServiceResponse.partial_success { rejected_log_records: 1 } + byte[] body = {0x0a, 0x02, 0x08, 0x01}; + exchange.getResponseHeaders().add("Content-Type", "application/x-protobuf"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var properties = new BusinessLogsProperties(); + var exporter = OtlpHttpLogRecordExporter.builder() + .setEndpoint("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/logs").build(); + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) { + var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now()); + pipeline.emit(new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "reviewer", snapshot)).orElseThrow(), Context.root()); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(requests.get()).isEqualTo(1); + assertThat(metrics.exported.count()).isEqualTo(1); + System.out.println("BUSINESS_LOGS partial-success: 200 rejected=1 body, SDK success=1, requests=1 (known 1.31 limitation)"); + } finally { server.stop(0); } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java new file mode 100644 index 000000000..cfbbfc845 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java @@ -0,0 +1,322 @@ +package com.iflytek.skillhub.observability.business; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.review.*; +import com.iflytek.skillhub.domain.security.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.metadata.*; +import com.iflytek.skillhub.domain.skill.service.*; +import com.iflytek.skillhub.domain.skill.validation.*; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.infra.jpa.*; +import com.iflytek.skillhub.storage.ObjectMetadata; +import com.iflytek.skillhub.storage.ObjectStorageService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.resources.Resource; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +/** Real service proxies, JPA repositories, schema migrations, commit/rollback and analytics adapter. */ +@DataJpaTest(showSql = false) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) +@Import({SkillOwnerAccessPolicy.class, JpaSkillRepositoryAdapter.class, SkillPublishService.class, + SkillReviewSubmitService.class, SkillGovernanceService.class, SkillVisibilityService.class, + SkillDownloadService.class, SkillSlugResolutionService.class, ReviewService.class, PromotionService.class, + ReviewPermissionChecker.class, BusinessLogsProducerPostgresTest.Configuration.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class BusinessLogsProducerPostgresTest { + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") + .withTmpFs(Map.of("/var/lib/postgresql/data", "rw")) + .withCreateContainerCmdModifier(cmd -> cmd.withName("skillhub-1009-producers-postgres")); + + @DynamicPropertySource + static void database(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @TestConfiguration + static class Configuration { + @Bean Clock clock() { return Clock.systemUTC(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } + @Bean VisibilityChecker visibilityChecker() { return new VisibilityChecker(); } + @Bean BusinessLogsTransactionTest.CapturingExporter exporter() { return new BusinessLogsTransactionTest.CapturingExporter(); } + @Bean BusinessLogsMetrics metrics() { return new BusinessLogsMetrics(new SimpleMeterRegistry()); } + @Bean(destroyMethod = "close") BusinessLogsPipeline pipeline(BusinessLogsTransactionTest.CapturingExporter exporter, BusinessLogsMetrics metrics) { + return new BusinessLogsPipeline(new BusinessLogsProperties(), Resource.empty(), exporter, metrics); + } + @Bean BusinessEventListener listener(BusinessLogsPipeline pipeline, BusinessLogsMetrics metrics) { + return new BusinessEventListener(new BusinessEventMapper(new BusinessLogsProperties()), pipeline, metrics); + } + } + + @MockBean SecurityScanService scanner; + @MockBean GovernanceNotificationService notifications; + @MockBean ReviewAvatarParticipant avatars; + @MockBean ObjectStorageService storage; + @MockBean SkillStorageDeletionCompensationService compensation; + @MockBean AuditLogService audit; + @MockBean SkillPackageValidator validator; + @MockBean SkillMetadataParser parser; + @MockBean PrePublishValidator prePublish; + @Autowired SkillPublishService publishing; + @Autowired SkillReviewSubmitService confirmations; + @Autowired ReviewService reviewing; + @Autowired PromotionService promoting; + @Autowired SkillGovernanceService governance; + @Autowired SkillVisibilityService visibility; + @Autowired SkillDownloadService downloads; + @Autowired SkillJpaRepository skills; + @Autowired SkillVersionJpaRepository versions; + @Autowired ReviewTaskJpaRepository reviews; + @Autowired PromotionRequestJpaRepository promotions; + @Autowired NamespaceJpaRepository namespaces; + @Autowired NamespaceMemberJpaRepository members; + @Autowired UserAccountJpaRepository users; + @Autowired SecurityAuditJpaRepository audits; + @Autowired PlatformTransactionManager transactions; + @Autowired BusinessLogsPipeline pipeline; + @Autowired BusinessLogsTransactionTest.CapturingExporter exporter; + + @BeforeEach + void setup() { + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + exporter.records.clear(); + when(scanner.isEnabled()).thenReturn(true); + when(validator.validate(anyList())).thenReturn(ValidationResult.pass()); + when(prePublish.validate(any())).thenReturn(ValidationResult.pass()); + when(parser.parse(anyString())).thenReturn(new SkillMetadata("business-probe", "Never export title", "3.0.0", "Never export body", Map.of())); + when(storage.exists(anyString())).thenReturn(true); + when(storage.getMetadata(anyString())).thenReturn(new ObjectMetadata(12, "application/zip", Instant.now())); + when(storage.generatePresignedUrl(anyString(), any(), anyString())).thenReturn("https://private.invalid/?signed=secret"); + } + + static Stream paths() { + return Stream.of("auto", "private", "review-approved", "review-rejected", "promotion-initial", + "promotion-update", "promotion-rejected", "archive", "unarchive", "visibility-narrow", + "visibility-expanded", "yank", "download").flatMap(path -> Stream.of(Arguments.of(path, false), Arguments.of(path, true))); + } + + @ParameterizedTest(name = "{0}, rollback={1}") + @MethodSource("paths") + void producerFactIsExactlyDocumentedAndOnlyAfterRealCommit(String path, boolean rollback) { + Fixture fixture = fixture(path); + List expected = expected(path); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + switch (path) { + case "auto" -> publishing.publishFromEntries(fixture.namespaceSlug(), + List.of(new PackageEntry("SKILL.md", "content".getBytes(java.nio.charset.StandardCharsets.UTF_8), 7, "text/markdown")), + fixture.owner(), SkillVisibility.PUBLIC, Set.of("SUPER_ADMIN")); + case "private" -> confirmations.confirmPublish(fixture.skillId(), fixture.uploadedId(), fixture.owner(), Map.of()); + case "review-approved" -> reviewing.approveReview(fixture.reviewId(), fixture.reviewer(), "checked", Map.of(), Set.of("SUPER_ADMIN")); + case "review-rejected" -> reviewing.rejectReview(fixture.reviewId(), fixture.reviewer(), "private reason", Map.of(), Set.of("SUPER_ADMIN")); + case "promotion-initial", "promotion-update" -> promoting.approvePromotion(fixture.promotionId(), fixture.reviewer(), "checked", Set.of("SUPER_ADMIN"), Set.of()); + case "promotion-rejected" -> promoting.rejectPromotion(fixture.promotionId(), fixture.reviewer(), "private reason", Set.of("SUPER_ADMIN")); + case "archive" -> governance.archiveSkill(fixture.skillId(), fixture.owner(), Map.of(), "not-exported-ip", "not-exported-ua", "private reason"); + case "unarchive" -> governance.unarchiveSkill(fixture.skillId(), fixture.owner(), Map.of(), "not-exported-ip", "not-exported-ua"); + case "visibility-narrow" -> visibility.change(fixture.skillId(), SkillVisibility.PRIVATE, fixture.owner(), Map.of(), Set.of()); + case "visibility-expanded" -> reviewing.approveReview(fixture.reviewId(), fixture.reviewer(), "checked", Map.of(), Set.of("SUPER_ADMIN")); + case "yank" -> governance.yankVersion(fixture.publishedId(), fixture.reviewer(), "not-exported-ip", "not-exported-ua", "private reason"); + case "download" -> assertThat(downloads.downloadVersion(fixture.namespaceSlug(), "business-probe", "1.0.0", null, Map.of()).presignedUrl()).contains("signed=secret"); + default -> throw new AssertionError(path); + } + assertThat(exporter.records).isEmpty(); + if (rollback) { status.setRollbackOnly(); } + }); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(exporter.records).extracting(r -> r.getAttributes().get(AttributeKey.stringKey("skillhub.event.name"))) + .containsExactlyInAnyOrderElementsOf(rollback ? List.of() : expected); + if (!rollback) { + for (var record : exporter.records) { + assertThat(record.getAttributes().toString()).doesNotContain("private reason", "not-exported", "secret", "business-probe", "Never export"); + String actor = record.getAttributes().get(AttributeKey.stringKey("user.id")); + if (path.equals("download")) { assertThat(actor).isNull(); } + else if (path.startsWith("review") || path.startsWith("promotion") || path.equals("visibility-expanded") || path.equals("yank")) { + assertThat(actor).isEqualTo(fixture.reviewer()); + } else { assertThat(actor).isEqualTo(fixture.owner()); } + } + } + // Persistent state independently proves rollback, not just listener suppression. + if (path.equals("private")) { + assertThat(((SkillVersionRepository) versions).findById(fixture.uploadedId()).orElseThrow().getStatus()) + .isEqualTo(rollback ? SkillVersionStatus.UPLOADED : SkillVersionStatus.PUBLISHED); + } + if (path.equals("review-approved")) { + assertThat(((ReviewTaskRepository) reviews).findById(fixture.reviewId()).orElseThrow().getStatus()) + .isEqualTo(rollback ? ReviewTaskStatus.PENDING : ReviewTaskStatus.APPROVED); + } + System.out.printf("BUSINESS_LOGS producer=%s rollback=%s received=%d expected=%d%n", path, rollback, exporter.records.size(), rollback ? 0 : expected.size()); + } + + private List expected(String path) { + return switch (path) { + case "auto", "private" -> List.of("skill.publication.effective"); + case "review-approved" -> List.of("skill.publication.effective", "skill.review.approved"); + case "review-rejected" -> List.of("skill.review.rejected"); + case "promotion-initial", "promotion-update" -> List.of("skill.publication.effective", "skill.promotion.approved"); + case "promotion-rejected" -> List.of("skill.promotion.rejected"); + case "archive", "unarchive" -> List.of("skill.status.changed"); + case "visibility-narrow", "visibility-expanded" -> List.of("skill.visibility.changed"); + case "yank" -> List.of("skill.version.yanked"); + case "download" -> List.of("skill.download.prepared"); + default -> throw new AssertionError(path); + }; + } + + static Stream unauthorizedPaths() { + return Stream.of("auto", "private", "review-approved", "review-rejected", "promotion-initial", + "promotion-update", "promotion-rejected", "archive", "unarchive", "visibility-narrow", + "visibility-expanded", "yank", "download"); + } + + @ParameterizedTest(name = "unauthorized {0}") + @MethodSource("unauthorizedPaths") + void deniedProducerPathsPersistNoMutationAndEmitNoFact(String path) { + Fixture fixture = fixture(path); + if (path.equals("download")) { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + Skill skill = ((SkillRepository) skills).findById(fixture.skillId()).orElseThrow(); + skill.setVisibility(SkillVisibility.PRIVATE); + skills.saveAndFlush(skill); + }); + } + Skill before = ((SkillRepository) skills).findById(fixture.skillId()).orElseThrow(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> { + switch (path) { + case "auto" -> publishing.publishFromEntries(fixture.namespaceSlug(), + List.of(new PackageEntry("SKILL.md", new byte[]{1}, 1, "text/markdown")), + fixture.reviewer(), SkillVisibility.PUBLIC, Set.of()); + case "private" -> confirmations.confirmPublish(fixture.skillId(), fixture.uploadedId(), fixture.reviewer(), Map.of()); + case "review-approved", "visibility-expanded" -> reviewing.approveReview(fixture.reviewId(), fixture.reviewer(), "checked", Map.of(), Set.of()); + case "review-rejected" -> reviewing.rejectReview(fixture.reviewId(), fixture.reviewer(), "reason", Map.of(), Set.of()); + case "promotion-initial", "promotion-update" -> promoting.approvePromotion(fixture.promotionId(), fixture.reviewer(), "checked", Set.of(), Set.of()); + case "promotion-rejected" -> promoting.rejectPromotion(fixture.promotionId(), fixture.reviewer(), "reason", Set.of()); + case "archive" -> governance.archiveSkill(fixture.skillId(), fixture.reviewer(), Map.of(), null, null, "reason"); + case "unarchive" -> governance.unarchiveSkill(fixture.skillId(), fixture.reviewer(), Map.of(), null, null); + case "visibility-narrow" -> visibility.change(fixture.skillId(), SkillVisibility.PRIVATE, fixture.reviewer(), Map.of(), Set.of()); + case "yank" -> governance.yankVersion(before, ((SkillVersionRepository) versions).findById(fixture.publishedId()).orElseThrow(), + fixture.reviewer(), Map.of(), null, null, "reason"); + case "download" -> downloads.downloadVersion(fixture.namespaceSlug(), "business-probe", "1.0.0", null, Map.of()); + default -> throw new AssertionError(path); + } + }).isInstanceOfAny(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class, + com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(exporter.records).isEmpty(); + Skill after = ((SkillRepository) skills).findById(fixture.skillId()).orElseThrow(); + assertThat(after.getStatus()).isEqualTo(before.getStatus()); + assertThat(after.getVisibility()).isEqualTo(before.getVisibility()); + assertThat(((SkillVersionRepository) versions).findById(fixture.publishedId()).orElseThrow().getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); + if (fixture.reviewId() != null) { + assertThat(((ReviewTaskRepository) reviews).findById(fixture.reviewId()).orElseThrow().getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + } + if (fixture.promotionId() != null) { + assertThat(((PromotionRequestRepository) promotions).findById(fixture.promotionId()).orElseThrow().getStatus()).isEqualTo(ReviewTaskStatus.PENDING); + } + System.out.printf("BUSINESS_LOGS denied producer=%s received=0; persisted state unchanged%n", path); + } + + private Fixture fixture(String path) { + return new TransactionTemplate(transactions).execute(status -> { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "business-owner-" + suffix; + String reviewer = "business-reviewer-" + suffix; + users.saveAndFlush(new UserAccount(owner, owner, null, null)); + users.saveAndFlush(new UserAccount(reviewer, reviewer, null, null)); + Namespace namespace = namespaces.saveAndFlush(new Namespace("business-" + suffix, "Never export namespace", owner)); + members.saveAndFlush(new NamespaceMember(namespace.getId(), owner, NamespaceRole.OWNER)); + SkillVisibility scope = path.equals("private") || path.equals("visibility-expanded") ? SkillVisibility.PRIVATE : SkillVisibility.PUBLIC; + Skill skill = skills.saveAndFlush(new Skill(namespace.getId(), "business-probe", owner, scope)); + SkillVersion published = new SkillVersion(skill.getId(), "1.0.0", owner); + published.setStatus(SkillVersionStatus.PUBLISHED); + published.setBundleReady(true); + published.setDownloadReady(true); + published.setPublishedAt(Instant.now()); + published = versions.saveAndFlush(published); + skill.setLatestVersionId(published.getId()); + if (path.equals("unarchive")) { skill.setStatus(SkillStatus.ARCHIVED); } + skills.saveAndFlush(skill); + SkillVersion uploaded = new SkillVersion(skill.getId(), "2.0.0", owner); + uploaded.setStatus(path.startsWith("review") ? SkillVersionStatus.PENDING_REVIEW : SkillVersionStatus.UPLOADED); + uploaded = versions.saveAndFlush(uploaded); + Long reviewId = null; + if (path.startsWith("review")) { + reviewId = reviews.saveAndFlush(new ReviewTask(uploaded.getId(), skill.getId(), namespace.getId(), uploaded.getVersion(), owner)).getId(); + } + if (path.equals("visibility-expanded")) { + SecurityAudit scan = new SecurityAudit(published.getId(), ScannerType.SKILL_SCANNER, "scan-" + suffix); + scan.setIsSafe(true); scan.setScannedAt(Instant.now()); audits.saveAndFlush(scan); + var task = ReviewTask.forVisibility(skill, SkillVisibility.PUBLIC, owner); + task.setVisibilityScanTaskId(scan.getTaskId()); + reviewId = reviews.saveAndFlush(task).getId(); + } + Long promotionId = null; + if (path.startsWith("promotion")) { + Namespace targetNamespace = new Namespace("global-" + suffix, "Never export global name", owner); + targetNamespace.setType(NamespaceType.GLOBAL); + targetNamespace = namespaces.saveAndFlush(targetNamespace); + PromotionRequest request = new PromotionRequest(skill.getId(), published.getId(), targetNamespace.getId(), owner); + if (path.equals("promotion-update")) { + Skill target = new Skill(targetNamespace.getId(), skill.getSlug(), owner, SkillVisibility.PUBLIC); + target.setSourceSkillId(skill.getId()); target = skills.saveAndFlush(target); + SkillVersion old = new SkillVersion(target.getId(), "0.9.0", owner); + old.setStatus(SkillVersionStatus.PUBLISHED); old = versions.saveAndFlush(old); + target.setLatestVersionId(old.getId()); skills.saveAndFlush(target); + PromotionRequest initial = new PromotionRequest(skill.getId(), published.getId(), targetNamespace.getId(), owner); + initial.setStatus(ReviewTaskStatus.APPROVED); initial.setTargetSkillId(target.getId()); initial.setTargetVersionId(old.getId()); + promotions.saveAndFlush(initial); + request.setRequestKind(PromotionRequestKind.UPDATE); request.setTargetSkillId(target.getId()); + } + promotionId = promotions.saveAndFlush(request).getId(); + } + return new Fixture(skill.getId(), published.getId(), uploaded.getId(), reviewId, promotionId, namespace.getSlug(), owner, reviewer); + }); + } + + private record Fixture(Long skillId, Long publishedId, Long uploadedId, Long reviewId, Long promotionId, + String namespaceSlug, String owner, String reviewer) {} +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java new file mode 100644 index 000000000..bd07d0426 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java @@ -0,0 +1,234 @@ +package com.iflytek.skillhub.observability.business; + +import com.iflytek.skillhub.domain.event.*; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.storage.ObjectStorageService; +import com.iflytek.skillhub.storage.ObjectMetadata; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.resources.Resource; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@Testcontainers +class BusinessLogsTransactionTest { + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") + .withTmpFs(java.util.Map.of("/var/lib/postgresql/data", "rw")) + .withCreateContainerCmdModifier(cmd -> cmd.withName("skillhub-1009-business-transactions")); + + private final BusinessEventSnapshot snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PRIVATE, + "approver", "user", "review", Instant.parse("2026-10-01T00:00:00Z")); + + @Test + void realPostgresCommitEmitsAndRollbackDoesNotForEveryIncludedFact() { + var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("create table if not exists analytics_transaction_probe (id int primary key)"); + var transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + var exporter = new CapturingExporter(); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var properties = new BusinessLogsProperties(); + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics); + var context = new AnnotationConfigApplicationContext()) { + context.registerBean(BusinessEventListener.class, + () -> new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics)); + context.refresh(); + var events = List.of( + new SkillPublishedEvent(1L, 2L, "approver", snapshot), + new SkillDownloadedEvent(1L, 2L, snapshot), + new ReviewApprovedEvent(3L, 1L, 2L, "approver", "owner", snapshot), + new ReviewRejectedEvent(3L, 1L, 2L, "approver", "owner", "secret", snapshot), + new PromotionApprovedEvent(4L, 1L, "approver", "owner", snapshot), + new PromotionRejectedEvent(4L, 1L, "approver", "owner", "secret", snapshot), + new SkillStatusChangedEvent(1L, SkillStatus.ACTIVE, SkillStatus.ARCHIVED, snapshot), + new SkillVisibilityChangedEvent(1L, SkillVisibility.PUBLIC, SkillVisibility.PRIVATE, snapshot), + new SkillVersionYankedEvent(1L, 2L, "approver", true, snapshot)); + transaction.executeWithoutResult(status -> { + jdbc.update("insert into analytics_transaction_probe values (1) on conflict do nothing"); + events.forEach(context::publishEvent); + assertThat(metrics.enqueued.count()).isZero(); + }); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(exporter.records).hasSize(9); + assertThat(exporter.records).extracting(r -> r.getAttributes().get(AttributeKey.stringKey("skillhub.event.name"))) + .containsExactlyInAnyOrderElementsOf(BusinessEventMapper.EVENT_NAMES); + transaction.executeWithoutResult(status -> { + jdbc.update("insert into analytics_transaction_probe values (2)"); + events.forEach(context::publishEvent); + status.setRollbackOnly(); + }); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("select count(*) from analytics_transaction_probe where id=2", Integer.class)).isZero(); + assertThat(exporter.records).hasSize(9); + context.publishEvent(new SkillDownloadedEvent(1L, 2L, snapshot)); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(exporter.records).hasSize(10); + assertThat(exporter.records.getLast().getTimestampEpochNanos()).isEqualTo(snapshot.occurredAt().getEpochSecond() * 1_000_000_000L); + System.out.println("BUSINESS_LOGS postgres: 9 commit facts, 0 rollback facts, 1 nontransactional fact"); + } + } + + @Test + void unsampledSpanCapturedBeforeCommitAndNoMdcOrBaggageExported() { + var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + var transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + var exporter = new CapturingExporter(); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var properties = new BusinessLogsProperties(); + var spanContext = SpanContext.create("12345678901234567890123456789012", "1234567890123456", TraceFlags.getDefault(), TraceState.getDefault()); + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) { + var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics); + transaction.executeWithoutResult(status -> { + try (var ignored = Span.wrap(spanContext).makeCurrent()) { + listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot)); + } + assertThat(Span.current().getSpanContext().isValid()).isFalse(); + }); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(exporter.records).hasSize(1); + assertThat(exporter.records.getFirst().getSpanContext()).isEqualTo(spanContext); + assertThat(exporter.records.getFirst().getSpanContext().isSampled()).isFalse(); + } + } + + @Test + void saturationDoesNotWaitForReceiverAndShutdownIsBounded() throws Exception { + var properties = new BusinessLogsProperties(); + properties.setMaxQueueSize(2); + properties.setMaxExportBatchSize(1); + properties.setScheduleDelay(Duration.ofMillis(10)); + properties.setShutdownTimeout(Duration.ofMillis(100)); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var entered = new CountDownLatch(1); + var pending = new CompletableResultCode(); + var exporter = new CapturingExporter() { + @Override public CompletableResultCode export(Collection records) { entered.countDown(); return pending; } + @Override public CompletableResultCode shutdown() { pending.fail(); return CompletableResultCode.ofSuccess(); } + }; + var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics); + var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics); + try { + listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot)); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + long start = System.nanoTime(); + for (int i = 0; i < 1000; i++) { listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot)); } + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + assertThat(elapsed).isLessThan(1000); + assertThat(metrics.enqueued.count()).isEqualTo(2); + assertThat(metrics.queueFull.count()).isEqualTo(999); + start = System.nanoTime(); + pipeline.close(); + long shutdown = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + assertThat(shutdown).isLessThan(1000); + System.out.printf("BUSINESS_LOGS saturation: 1000 caller events in %d ms, drops=999, shutdown=%d ms%n", elapsed, shutdown); + } finally { pipeline.close(); pending.fail(); } + } + + @Test + void mapperAndExporterExceptionsCannotEscapeBusinessThread() { + var properties = new BusinessLogsProperties(); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var exporter = new CapturingExporter() { + @Override public CompletableResultCode export(Collection records) { throw new IllegalStateException("must not be logged"); } + }; + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) { + var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics); + assertThatCode(() -> listener.onEvent(null)).doesNotThrowAnyException(); + assertThat(metrics.errors.count()).isEqualTo(1); + assertThatCode(() -> listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot))).doesNotThrowAnyException(); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(metrics.exportFailures.count()).isEqualTo(1); + } + } + + @Test + void actualNontransactionalDownloadProducerEmitsOnceAfterPermissionsAndNeverForReviewOrDeniedAccess() { + var properties = new BusinessLogsProperties(); + var exporter = new CapturingExporter(); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics); + var context = new AnnotationConfigApplicationContext()) { + context.registerBean(BusinessEventListener.class, + () -> new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics)); + context.refresh(); + var namespaces = mock(NamespaceRepository.class); + var skills = mock(SkillRepository.class); + var versions = mock(SkillVersionRepository.class); + var storage = mock(ObjectStorageService.class); + var skill = new Skill(7L, "never-export-this-slug", "owner", SkillVisibility.PUBLIC); + org.springframework.test.util.ReflectionTestUtils.setField(skill, "id", 1L); + skill.setLatestVersionId(2L); + var version = new SkillVersion(1L, "1.0.0", "owner"); + org.springframework.test.util.ReflectionTestUtils.setField(version, "id", 2L); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + var namespace = new Namespace("test", "Never export namespace name", "owner"); + org.springframework.test.util.ReflectionTestUtils.setField(namespace, "id", 7L); + when(namespaces.findBySlug("test")).thenReturn(java.util.Optional.of(namespace)); + when(skills.findByNamespaceIdAndSlug(7L, skill.getSlug())).thenReturn(List.of(skill)); + when(versions.findById(2L)).thenReturn(java.util.Optional.of(version)); + when(storage.exists("packages/1/2/bundle.zip")).thenReturn(true); + when(storage.getMetadata("packages/1/2/bundle.zip")).thenReturn(new ObjectMetadata(12, "application/zip", Instant.now())); + when(storage.generatePresignedUrl(anyString(), any(), anyString())).thenReturn("https://secret-signed-url.invalid/?token=not-exported"); + var service = new SkillDownloadService(namespaces, skills, versions, mock(SkillVersionStatsRepository.class), + mock(SkillFileRepository.class), mock(SkillTagRepository.class), storage, new VisibilityChecker(), + context, new SkillSlugResolutionService(skills)); + var response = service.downloadLatest("test", skill.getSlug(), null, java.util.Map.of()); + assertThat(response.presignedUrl()).contains("secret-signed-url"); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(exporter.records).hasSize(1); + assertThat(exporter.records.getFirst().getAttributes().get(AttributeKey.stringKey("user.id"))).isNull(); + assertThat(exporter.records.getFirst().getAttributes().toString()).doesNotContain("not-exported", skill.getSlug()); + service.downloadReviewVersion(skill, version); + skill.setVisibility(SkillVisibility.PRIVATE); + assertThatThrownBy(() -> service.downloadLatest("test", skill.getSlug(), null, java.util.Map.of())) + .isInstanceOf(DomainForbiddenException.class); + assertThatThrownBy(() -> service.downloadLatest("test", skill.getSlug(), "stranger", java.util.Map.of())) + .isInstanceOf(DomainForbiddenException.class); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(exporter.records).hasSize(1); + System.out.println("BUSINESS_LOGS real download service: anonymous prepared=1, review/anonymous-private/unauthorized-private=0"); + } + } + + static class CapturingExporter implements LogRecordExporter { + final List records = new CopyOnWriteArrayList<>(); + @Override public CompletableResultCode export(Collection records) { this.records.addAll(records); return CompletableResultCode.ofSuccess(); } + @Override public CompletableResultCode flush() { return CompletableResultCode.ofSuccess(); } + @Override public CompletableResultCode shutdown() { return CompletableResultCode.ofSuccess(); } + } +} From eb6fba515d7a8797c93c3b6b86ba3ab485066c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= <258399167+xiaoyaner0201@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:11:04 +0800 Subject: [PATCH 03/21] feat(deploy): wire business Logs settings and Secret references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com> --- .env.release.example | 19 ++++ charts/skillhub/README.md | 2 + .../skillhub/templates/server-deployment.yaml | 15 ++++ charts/skillhub/values.schema.json | 20 +++++ charts/skillhub/values.yaml | 9 ++ compose.release.yml | 14 +++ scripts/tests/test_business_logs_config.py | 89 +++++++++++++++++++ scripts/validate-release-config.sh | 12 +++ 8 files changed, 180 insertions(+) create mode 100644 scripts/tests/test_business_logs_config.py diff --git a/.env.release.example b/.env.release.example index daac9786f..63faba6aa 100644 --- a/.env.release.example +++ b/.env.release.example @@ -78,6 +78,25 @@ MANAGEMENT_OTLP_TRACING_ENDPOINT= SKILLHUB_OTLP_TIMEOUT=5s SKILLHUB_OTLP_COMPRESSION=gzip +# Independent, lossy business analytics. Default off; no receiver required. +# Full catalog, Secret injection and Collector query example: docs/business-event-logs.md +SKILLHUB_BUSINESS_LOGS_ENABLED=false +SKILLHUB_BUSINESS_LOGS_ENDPOINT= +# URL-encoded name=value pairs, comma-separated; inject from deployment Secrets, never commit real values. +SKILLHUB_BUSINESS_LOGS_HEADERS= +SKILLHUB_BUSINESS_LOGS_TIMEOUT=3s +SKILLHUB_BUSINESS_LOGS_COMPRESSION=gzip +SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE=2048 +SKILLHUB_BUSINESS_LOGS_MAX_EXPORT_BATCH_SIZE=256 +SKILLHUB_BUSINESS_LOGS_SCHEDULE_DELAY=1s +SKILLHUB_BUSINESS_LOGS_SHUTDOWN_TIMEOUT=5s +SKILLHUB_BUSINESS_LOGS_RETRY_MAX_ATTEMPTS=3 +SKILLHUB_BUSINESS_LOGS_RETRY_INITIAL_BACKOFF=200ms +SKILLHUB_BUSINESS_LOGS_RETRY_MAX_BACKOFF=1s +SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST=skill.publication.effective,skill.download.prepared,skill.review.approved,skill.review.rejected,skill.promotion.approved,skill.promotion.rejected,skill.status.changed,skill.visibility.changed,skill.version.yanked +# Empty = all namespaces, INCLUDING private-resource IDs; never names/content. +SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST= + # Zero-config runtime validation uses local storage. # Switch to `s3` and fill the fields below before a real production deployment. SKILLHUB_STORAGE_PROVIDER=local diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index e7eb76fe6..1bd884044 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -9,6 +9,8 @@ - **数据层**:使用 Bitnami PostgreSQL/Redis,支持主从复制、哨兵模式 - **安全**:TLS 证书管理、Secret 密码保护;Bitnami 数据组件默认提供 NetworkPolicy - **可观测性**:内置 Prometheus metrics exporter +- **业务事件 Logs(默认关闭)**:`server.businessLogs.enabled`、`endpoint` 与 `headersSecretRef`(引用现有 Secret); + 其他预算/过滤配置使用 `server.extraEnv`,不增加默认 Collector。见[完整运行手册](../../docs/business-event-logs.md)。 ## 快速开始 diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 1b8e503b9..c3443dcfd 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -79,6 +79,21 @@ spec: value: {{ $profiles | quote }} - name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED value: {{ .Values.server.suiteReviewWritesEnabled | quote }} + {{- $businessLogs := .Values.server.businessLogs | default dict }} + - name: SKILLHUB_BUSINESS_LOGS_ENABLED + value: {{ $businessLogs.enabled | default false | quote }} + - name: SKILLHUB_BUSINESS_LOGS_ENDPOINT + value: {{ $businessLogs.endpoint | default "" | quote }} + {{- with $businessLogs.headersSecretRef }} + {{- if .name }} + - name: SKILLHUB_BUSINESS_LOGS_HEADERS + valueFrom: + secretKeyRef: + name: {{ .name | quote }} + key: {{ .key | quote }} + optional: false + {{- end }} + {{- end }} - name: SKILLHUB_GITHUB_SYNC_STAGING_CAPACITY_LIMIT_BYTES value: {{ .Values.githubSync.stagingCapacityLimitBytes | quote }} {{- if .Values.githubSync.enabled }} diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 40bba2ab7..23aa75111 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -452,6 +452,26 @@ "enabled": { "type": "boolean" }, "replicaCount": { "type": "integer", "minimum": 1 }, "suiteReviewWritesEnabled": { "type": "boolean" }, + "businessLogs": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "endpoint", "headersSecretRef"], + "properties": { + "enabled": { "type": "boolean" }, + "endpoint": { "type": "string", "pattern": "^$|^https?://[^@?#]+/[^?#]+$" }, + "headersSecretRef": { + "type": "object", + "additionalProperties": false, + "required": ["name", "key"], + "properties": { + "name": { "$ref": "#/definitions/optionalDnsLabel" }, + "key": { "type": "string", "minLength": 1 } + } + } + }, + "if": { "properties": { "enabled": { "const": true } } }, + "then": { "properties": { "endpoint": { "minLength": 1 } } } + }, "image": { "$ref": "#/definitions/image" }, "dependencyWait": { "type": "object", diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index f548f2fe6..3ad732a56 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -312,6 +312,15 @@ server: # Server instance supports typed review subjects; fresh non-rolling installs may enable it directly. suiteReviewWritesEnabled: false + # Independent of tracing; optional OTLP Logs receiver. Other budgets use server.extraEnv. + businessLogs: + enabled: false + endpoint: "" + # Existing Secret containing URL-encoded OTLP header pairs. No inline credentials. + headersSecretRef: + name: "" + key: headers + image: registry: "" tag: "" diff --git a/compose.release.yml b/compose.release.yml index 876655749..963ef479c 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -118,6 +118,20 @@ services: SKILLHUB_BUILTIN_SKILLS_ENABLED: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:-true} SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false} SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none} + SKILLHUB_BUSINESS_LOGS_ENABLED: ${SKILLHUB_BUSINESS_LOGS_ENABLED:-false} + SKILLHUB_BUSINESS_LOGS_ENDPOINT: ${SKILLHUB_BUSINESS_LOGS_ENDPOINT:-} + SKILLHUB_BUSINESS_LOGS_HEADERS: ${SKILLHUB_BUSINESS_LOGS_HEADERS:-} + SKILLHUB_BUSINESS_LOGS_TIMEOUT: ${SKILLHUB_BUSINESS_LOGS_TIMEOUT:-3s} + SKILLHUB_BUSINESS_LOGS_COMPRESSION: ${SKILLHUB_BUSINESS_LOGS_COMPRESSION:-gzip} + SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE: ${SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE:-2048} + SKILLHUB_BUSINESS_LOGS_MAX_EXPORT_BATCH_SIZE: ${SKILLHUB_BUSINESS_LOGS_MAX_EXPORT_BATCH_SIZE:-256} + SKILLHUB_BUSINESS_LOGS_SCHEDULE_DELAY: ${SKILLHUB_BUSINESS_LOGS_SCHEDULE_DELAY:-1s} + SKILLHUB_BUSINESS_LOGS_SHUTDOWN_TIMEOUT: ${SKILLHUB_BUSINESS_LOGS_SHUTDOWN_TIMEOUT:-5s} + SKILLHUB_BUSINESS_LOGS_RETRY_MAX_ATTEMPTS: ${SKILLHUB_BUSINESS_LOGS_RETRY_MAX_ATTEMPTS:-3} + SKILLHUB_BUSINESS_LOGS_RETRY_INITIAL_BACKOFF: ${SKILLHUB_BUSINESS_LOGS_RETRY_INITIAL_BACKOFF:-200ms} + SKILLHUB_BUSINESS_LOGS_RETRY_MAX_BACKOFF: ${SKILLHUB_BUSINESS_LOGS_RETRY_MAX_BACKOFF:-1s} + SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST: ${SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST-skill.publication.effective,skill.download.prepared,skill.review.approved,skill.review.rejected,skill.promotion.approved,skill.promotion.rejected,skill.status.changed,skill.visibility.changed,skill.version.yanked} + SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST: ${SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST:-} SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-json} SKILLHUB_LOG_ASYNC_QUEUE_SIZE: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:-1024} SKILLHUB_SERVICE_VERSION: ${SKILLHUB_SERVICE_VERSION:-unknown} diff --git a/scripts/tests/test_business_logs_config.py b/scripts/tests/test_business_logs_config.py new file mode 100644 index 000000000..2b73c4a08 --- /dev/null +++ b/scripts/tests/test_business_logs_config.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Deployment contract checks using only synthetic inputs; never load local credential files.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +BASE = """SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.invalid +SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=synthetic-validation-only-not-a-secret-value +POSTGRES_DB=skillhub +POSTGRES_USER=skillhub +POSTGRES_PASSWORD=synthetic-validation-only-not-a-secret +BOOTSTRAP_ADMIN_ENABLED=false +SESSION_COOKIE_SECURE=true +SKILLHUB_STORAGE_PROVIDER=local +""" + + +class BusinessLogsDeploymentTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="skillhub-1009-config-") + self.addCleanup(self.directory.cleanup) + self.fixture = Path(self.directory.name) / "synthetic.env" + self.fixture.write_text(BASE) + self.environment = dict(os.environ, COMPOSE_DISABLE_ENV_FILE="true") + # Explicitly pin test settings rather than inheriting operator telemetry configuration. + for key in list(self.environment): + if key.startswith("SKILLHUB_BUSINESS_LOGS_"): + del self.environment[key] + + def run_command(self, *command): + return subprocess.run(command, cwd=ROOT, env=self.environment, capture_output=True, text=True, timeout=60) + + def validate(self, extra): + self.fixture.write_text(BASE + extra) + return self.run_command("sh", "scripts/validate-release-config.sh", str(self.fixture)) + + def test_release_default_off_and_enabled_requires_endpoint(self): + self.assertEqual(self.validate("").returncode, 0) + result = self.validate("SKILLHUB_BUSINESS_LOGS_ENABLED=true\n") + self.assertNotEqual(result.returncode, 0) + self.assertIn("SKILLHUB_BUSINESS_LOGS_ENDPOINT is required", result.stderr) + self.assertEqual(self.validate("SKILLHUB_BUSINESS_LOGS_ENABLED=true\nSKILLHUB_BUSINESS_LOGS_ENDPOINT=http://collector:4318/v1/logs\n").returncode, 0) + + def test_release_rejects_secret_bearing_url_without_printing_it(self): + result = self.validate("SKILLHUB_BUSINESS_LOGS_ENABLED=true\nSKILLHUB_BUSINESS_LOGS_ENDPOINT=https://user:fake-secret@receiver.invalid/v1/logs?token=fake-query\n") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("fake-secret", result.stdout + result.stderr) + self.assertNotIn("fake-query", result.stdout + result.stderr) + + def test_compose_defaults_and_explicit_forwarding(self): + command = ("docker", "compose", "--env-file", str(self.fixture), "-p", "skillhub-1009-config", "-f", "compose.release.yml", "config", "--format", "json") + result = self.run_command(*command) + self.assertEqual(result.returncode, 0, result.stderr) + environment = json.loads(result.stdout)["services"]["server"]["environment"] + self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENABLED"], "false") + self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENDPOINT"], "") + self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE"], "2048") + self.fixture.write_text(BASE + "SKILLHUB_BUSINESS_LOGS_ENABLED=true\nSKILLHUB_BUSINESS_LOGS_ENDPOINT=http://collector:4318/v1/logs\nSKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST=7,12\nSKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST=\n") + result = self.run_command(*command) + self.assertEqual(result.returncode, 0, result.stderr) + environment = json.loads(result.stdout)["services"]["server"]["environment"] + self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENABLED"], "true") + self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENDPOINT"], "http://collector:4318/v1/logs") + self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST"], "7,12") + self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST"], "") + + @unittest.skipUnless(shutil.which("helm"), "helm executable required") + def test_helm_defaults_secret_reference_and_invalid_enabled_config(self): + command = ("helm", "template", "skillhub-1009-config", "charts/skillhub", "-f", "charts/skillhub/tests/test-values.yaml", "--show-only", "templates/server-deployment.yaml") + result = self.run_command(*command) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertRegex(result.stdout, 'name: SKILLHUB_BUSINESS_LOGS_ENABLED\\s+value: "false"') + self.assertRegex(result.stdout, 'name: SKILLHUB_BUSINESS_LOGS_ENDPOINT\\s+value: ""') + self.assertNotIn("name: SKILLHUB_BUSINESS_LOGS_HEADERS", result.stdout) + result = self.run_command(*command, "--set", "server.businessLogs.enabled=true") + self.assertNotEqual(result.returncode, 0) + result = self.run_command(*command, "--set", "server.businessLogs.enabled=true", "--set", "server.businessLogs.endpoint=http://collector:4318/v1/logs", "--set", "server.businessLogs.headersSecretRef.name=analytics-credentials") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.count("name: SKILLHUB_BUSINESS_LOGS_HEADERS"), 1) + self.assertRegex(result.stdout, 'name: SKILLHUB_BUSINESS_LOGS_HEADERS\\s+valueFrom:\\s+secretKeyRef:\\s+name: "analytics-credentials"\\s+key: "headers"\\s+optional: false') + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index f84cbb4e1..d3ea3c14f 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -315,6 +315,18 @@ validate_boolean SESSION_COOKIE_SECURE validate_boolean BOOTSTRAP_ADMIN_ENABLED validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO validate_boolean SKILLHUB_BUILTIN_SKILLS_ENABLED +validate_boolean SKILLHUB_BUSINESS_LOGS_ENABLED +# Do not echo the endpoint or authentication headers. Full URL/budget validation is at startup. +if [ "${SKILLHUB_BUSINESS_LOGS_ENABLED:-false}" = "true" ]; then + require_non_empty SKILLHUB_BUSINESS_LOGS_ENDPOINT + case "${SKILLHUB_BUSINESS_LOGS_ENDPOINT:-}" in + http://*|https://*) ;; + *) error "SKILLHUB_BUSINESS_LOGS_ENDPOINT requires an HTTP(S) Logs URL" ;; + esac + case "${SKILLHUB_BUSINESS_LOGS_ENDPOINT:-}" in + *'@'*|*'?'*|*'#'*) error "SKILLHUB_BUSINESS_LOGS_ENDPOINT must not contain credentials, query or fragment" ;; + esac +fi validate_boolean SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET From 894e5860ce19059a27dab8cf94642aba56d33a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= <258399167+xiaoyaner0201@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:11:05 +0800 Subject: [PATCH 04/21] docs(observability): document business event Logs and acceptance evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com> --- docs/09-deployment.md | 3 +- docs/business-event-logs.md | 163 ++++++++++++++++++ docs/observability-developer-guide.md | 8 +- docs/skillhub/en/guide/deployment.md | 10 ++ docs/skillhub/guide/deployment.md | 9 + .../acceptance.md | 60 +++++++ .../add-otlp-business-event-logs/design.md | 39 +++++ .../add-otlp-business-event-logs/proposal.md | 23 +++ .../specs/business-event-logs/spec.md | 64 +++++++ .../add-otlp-business-event-logs/tasks.md | 26 +++ 10 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 docs/business-event-logs.md create mode 100644 openspec/changes/add-otlp-business-event-logs/acceptance.md create mode 100644 openspec/changes/add-otlp-business-event-logs/design.md create mode 100644 openspec/changes/add-otlp-business-event-logs/proposal.md create mode 100644 openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md create mode 100644 openspec/changes/add-otlp-business-event-logs/tasks.md diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 9299b9bff..04706d6d9 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -617,6 +617,7 @@ override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 ` | 请求关联 | 响应头和日志中的 `X-Request-Id` / `request.id` | | 日志 | 文本或 ECS 风格 JSON,均输出到容器 stdout / stderr | | Trace | `none`、Micrometer + OTel SDK、或外部 Java Agent 三选一 | +| 业务事实 | 默认关闭的独立 OTLP Logs 出口;[事件目录、配置、Secret、Collector 实收查询与可靠性边界](./business-event-logs.md) | | 指标 | Spring Boot Actuator;Prometheus 是可选后端,不是 Trace 前置条件 | ### 10.1 通用配置 @@ -643,7 +644,7 @@ JSON 日志使用以下稳定字段: ### 10.2 三种 Tracing 模式 -三种模式只能选择一种,切换后需要重启: +三种 Trace 模式只能选择一种,切换后需要重启。该限制不禁止独立配置业务 Logs;下表及本节的 endpoint 限制仅指 TRACE endpoint: | 模式 | 适用场景 | 必需配置 | |------|----------|----------| diff --git a/docs/business-event-logs.md b/docs/business-event-logs.md new file mode 100644 index 000000000..bb2ff56c6 --- /dev/null +++ b/docs/business-event-logs.md @@ -0,0 +1,163 @@ +# Business-event Logs over OTLP / OTLP 业务事件日志 + +Default off. This is lossy analytics, NOT audit, billing, notification or installation confirmation. Use the official server image built from this change; no custom application code, vendor SDK or Collector is required. The receiver must support OTLP/HTTP protobuf Logs. / 默认关闭;用于有损分析,不替代审计、计费、可靠通知或安装确认。只需对应版本官方镜像与运维配置,Collector 可选。 + +## Event catalog / 事件目录 (contract version 1) + +Every row requires `skillhub.event.name`, `skillhub.skill.id`, `skillhub.namespace.id`, `skillhub.visibility`, `skillhub.source`, `skillhub.actor.type`, `log.record.uid` and producer occurrence `Timestamp`. IDs are numeric except the local opaque human `user.id` and UUID UID. Actor type is `user` (including anonymous/unknown user requests) or `system`. A known human adds `user.id`; anonymous/unknown and system actors omit it. / 所有事件都有上述字段;主体类型为 user(含匿名或未知请求)或 system;已知人类操作者额外使用本实例不透明 `user.id`,匿名、未知及系统主体省略该字段。 + +| Event | When / 时机 | Extra fields / 额外字段 | Actor and source / 操作者与来源 | +| --- | --- | --- | --- | +| `skill.publication.effective` | Actual transition into PUBLISHED, exported only after commit / 实际发布且事务提交 | `skillhub.version.id` | Auto-publication: submitting privileged human, `auto_publish`; internal forced publication: `system`, no `user.id`. PRIVATE confirmation: confirmer, `private_confirm`. Review/promotion approval: actual approver, `review` / `promotion`, NOT necessarily uploader or owner / 审核或提升时为实际批准人而非上传者 | +| `skill.download.prepared` | Authorized PUBLISHED request has built its download result and updated existing counters; before stream opens/redirect / 已授权的已发布版本已构建下载结果,尚未打开流或跳转 | `skillhub.version.id` | Current authenticated local user, otherwise anonymous (`user` without `user.id`); `download` / 当前调用人,否则 user 类型且省略 ID | +| `skill.review.approved` | Existing content-version review approved, after commit / 内容版本审核通过并提交 | `skillhub.version.id`, `skillhub.review.id` | Actual reviewer; `review` / 实际审核人 | +| `skill.review.rejected` | Existing content-version review rejected, after commit / 内容版本审核拒绝并提交 | `skillhub.version.id`, `skillhub.review.id` | Actual reviewer; `review`; no reason/comment / 不导出拒绝原因 | +| `skill.promotion.approved` | Initial or update promotion approved, after commit / 首次或更新提升批准并提交 | `skillhub.promotion.id` | Actual approver; `promotion`; skill/namespace refer to SOURCE, not derived target / 技能及空间指来源而非全局目标 | +| `skill.promotion.rejected` | Promotion rejected, after commit / 提升拒绝并提交 | `skillhub.promotion.id` | Actual reviewer; `promotion`; SOURCE scope; no reason / 来源范围,不导出原因 | +| `skill.status.changed` | Archive/unarchive actually changes status, after commit / 归档或取消归档状态实际变化并提交 | `skillhub.status.old`, `skillhub.status.new` | Owner, namespace manager or authorized admin invoking operation; `governance` / 实际执行的所有者、空间管理者或管理员 | +| `skill.visibility.changed` | Effective access-scope change: immediate narrowing OR approved expansion, after commit / 缩小范围立即生效,扩大范围审核通过后生效;均提交后导出 | `skillhub.visibility.old`, `skillhub.visibility.new` | Narrowing requester or expansion approver; `visibility` / 缩小时请求人,扩大时批准人 | +| `skill.version.yanked` | Existing published-version yank, after commit / 已发布版本撤回分发并提交 | `skillhub.version.id` | Actual owner/manager/admin; `governance` / 实际执行人 | + +Publication covers all four producers: `SkillPublishService`, `SkillReviewSubmitService`, `ReviewService`, `PromotionService`. The reserved `builtin-skill-publisher` identity is classified as `system` even through the ordinary privileged auto-publication path (bootstrap verifies that it is a system account). Approval may correctly create TWO different facts: publication plus review/promotion approval. These are not duplicate records. Promotion approval's publication references the TARGET skill/version/namespace; its promotion fact references the SOURCE. Namespace filtering may therefore keep only one. / 发布覆盖四条生产路径;内置技能的保留系统身份即便走普通特权直发,也标记为 system,启动器会校验其系统账号属性;批准同时产生发布及审核/提升两个不同事实,不是重复。提升的发布事件指目标,提升决定事件指来源,空间过滤可能只保留其一。 + +Timestamp is captured immediately before the producer publishes its domain event, not `publishedAt`, transaction commit time or async-send time. Transaction synchronization captures the record and valid span identity at event dispatch, admits only after commit, and never emits on rollback. Outside a transaction (notably the shared download service), admission is immediate. No entity lookup happens in the telemetry listener. / 时间是生产点发布领域事件前的时刻,不是提交或发送时刻;事务内仅提交后入队,回滚不发;无事务下载直接入队。 + +Download means prepared, NOT delivered, complete, installed or executed. Web/v1/CLI share the producer; authorized previews of unpublished versions and review packages do not emit. Compat redirects alone, Suite plans, resolve/detail polling, direct object-storage access, URL reuse and client caches are not new registry download-prepared facts. / 下载准备不等于送达、传输完成、安装或执行;未发布预览、审核包、套装计划、查询轮询不计入。对象存储直取、URL 重用及缓存可绕过注册表。 + +Visibility facts cover the existing dedicated `SkillVisibilityService` event producers. Visibility assigned as part of uploading/publishing a content version has no separate visibility event in the existing domain model; its final value is included on the publication fact. / 可见范围事实覆盖独立范围调整的既有生产点;内容上传或发布时顺带赋予的可见范围没有独立领域事件,只在发布事实中携带最终值。 + +Deferred: metadata/avatar review decisions without an existing decision event (effective visibility changes ARE included), hide/unhide, promotion revocation, review submission/withdrawal, Suite events, searches/views, transfer completion, installation and durable replay. Existing audit/notification/search listeners and authorization rules are unchanged. Legacy events constructed without a producer snapshot are intentionally ignored, not enriched from a database at export time. / 其余事件分期;不变更既有审计、通知、搜索或授权;缺少生产点快照的旧式事件不导出,不在导出时查库补全。 + +## Representation and data boundary / 数据模型与边界 + +- Dedicated scope `com.iflytek.skillhub.business`, instrumentation version `1`; Spring Boot 3.2.3 BOM / OTel Java 1.31.0 unchanged. `skillhub.event.name` is a documented custom attribute because 1.31 lacks `setEventName`. This is standard OTLP LogRecord transport, not a claim of implementing the latest experimental OTel Events semantic conventions. `log.record.uid` borrows the experimental uniqueness convention; contract version 1 locks its meaning here, not an unpinned global semconv schema. +- Resource is ONLY `service.name` (existing `spring.application.name`), `service.version` (existing SkillHub setting), `deployment.environment` (existing SkillHub environment). No host/cloud/process/resource autodetection. Occurrence uses Timestamp; ObservedTimestamp is left to SDK. Body is empty. +- Only an already-valid current OTel span context is associated, including unsampled spans. No span is created and no MDC/request ID is converted into trace identity. Trace absence or sampling never filters business records; an associated unsampled trace may not be queryable. +- The sole mapper copies explicit fixed fields, never an event/entity map. Maximum 16 attributes, 256 UTF-16 code units per string (SDK limit as defense-in-depth), three bounded Resource strings, no arbitrary arrays/maps. Unknown event types/sources are ignored. IDs of private skills and namespaces ARE included by default when this instance-level feature is enabled. Empty namespace allowlist means all namespaces. This keeps review/governance analytics interpretable without exporting names or performing enrichment queries. IDs remain linkable/personal data, NOT anonymized; restrict the allowlist, receiver access, retention and destination accordingly. +- Never exported: skill/namespace names or slugs, package/body/metadata content, titles, summaries, rejection comments/reasons, submitter/owner identity, email/name, query terms, tokens/passwords/cookies, signed URLs, IP, UA, arbitrary request headers, request IDs, MDC or baggage. Authentication headers are transport-only, not attributes. + +中文:独立记录器,不桥接应用日志,不更改 Trace;Resource 仅包含服务名、版本及环境。白名单最多 16 个属性、字符串最多 256 个 UTF-16 单元。默认包括私有技能及空间的 ID,但不包括名称/内容;ID 仍可关联个人或资源,须按敏感数据管理。命名空间白名单建议上线前明确限制。正文、原因、密钥、签名 URL、IP/UA、任意请求头和 baggage 一律不输出。 + +## Complete configuration / 完整配置 + +All settings are startup-only: recreate/restart the server after changes. Property prefix is `skillhub.observability.business-logs.`. Environment suffixes below use `SKILLHUB_BUSINESS_LOGS_`. `application.yml` and release Compose explicitly map them; manual SDK builders DO NOT automatically read `OTEL_*`. / 所有配置启动时读取,需重建/重启;以下环境变量使用统一前缀,不自动读取 `OTEL_*`。 + +| Property suffix / 属性 | Env suffix / 环境变量后缀 | Default / 默认 | Meaning / 范围与含义 | +| --- | --- | --- | --- | +| `enabled` | `ENABLED` | `false` | Opt-in, independent of tracing / 独立开关 | +| `endpoint` | `ENDPOINT` | empty | Required when enabled; full HTTP(S) Logs URL, used AS-IS, normally `/v1/logs`; NOT `/v1/traces`. No userinfo/query/fragment or known metadata literal targets / 启用必填完整 Logs URL,不追加路径 | +| `headers` | `HEADERS` | empty | At most 16 comma-separated URL-encoded `name=value` pairs; total 8192 characters; ASCII values, no controls / 最多 16 对 URL 编码请求头,总长 8192;使用 Secret | +| `timeout` | `TIMEOUT` | `3s` | SDK HTTP timeout; 100ms..30s / 有界超时 | +| `compression` | `COMPRESSION` | `gzip` | `gzip` or `none` | +| `max-queue-size` | `MAX_QUEUE_SIZE` | `2048` | 1..65536 admitted records INCLUDING queued and in-flight, not bytes or precommit callbacks / 接纳后的队列及在途记录,不按字节,不包括提交前回调 | +| `max-export-batch-size` | `MAX_EXPORT_BATCH_SIZE` | `256` | 1..min(4096,queue size) / 每批上限 | +| `schedule-delay` | `SCHEDULE_DELAY` | `1s` | 10ms..60s; SDK batch schedule / 批处理调度间隔 | +| `shutdown-timeout` | `SHUTDOWN_TIMEOUT` | `5s` | 100ms..30s; maximum caller wait for graceful drain / 退出等待上限 | +| `retry-max-attempts` | `RETRY_MAX_ATTEMPTS` | `3` | 1..5 INCLUDING first request; 1 disables retry / 含首次请求,1 为不重试 | +| `retry-initial-backoff` | `RETRY_INITIAL_BACKOFF` | `200ms` | 1ms..5s, no greater than max backoff / 初始退避 | +| `retry-max-backoff` | `RETRY_MAX_BACKOFF` | `1s` | 1ms..10s, SDK exponential multiplier 2 with jitter / 最大退避 | +| `event-allowlist` | `EVENT_ALLOWLIST` | all 9 catalog names | Comma-separated exact names; empty exports none; unknown name fails startup / 精确事件名,空为不导出,未知值启动失败 | +| `namespace-allowlist` | `NAMESPACE_ALLOWLIST` | empty | Empty = all including private; otherwise up to 1000 positive numeric namespace IDs, comma-separated / 空为全部(含私有),否则最多 1000 个正整数 ID | + +Related existing Resource settings: `spring.application.name` (`skillhub`, environment `SPRING_APPLICATION_NAME`), `skillhub.observability.service-version` (`SKILLHUB_SERVICE_VERSION`, `unknown`), `skillhub.observability.service-environment` (`SKILLHUB_SERVICE_ENVIRONMENT`, `local`; release Compose `production`). These strings are bounded to 256 code units. Do not put credentials in service identity. / 沿用既有服务标识配置;不要在服务标识中放凭证。 + +A generic OTLP base endpoint normally appends a signal path in OTel autoconfiguration; THIS setting is Logs-specific, uses the given URL unchanged, and requires an explicit non-root path. Invalid enabled configuration fails startup without echoing endpoint/header values. Localhost/private-network receivers are valid. Destination is trusted operator configuration, not an end-user setting: use egress firewall/NetworkPolicy and DNS controls to allow only the authorized receiver and block cloud metadata, including aliases/rebinding. The managed 1.31 OkHttp sender follows redirects and exposes no redirect switch on its public Logs exporter builder: configure the receiver not to redirect and enforce destination/HTTPS restrictions at egress. Literal validation is not a complete SSRF firewall. / 本配置不是通用 base endpoint,不追加信号路径;允许自托管内网目标。1.31 发送器会跟随重定向,公开 builder 无关闭开关,接收端应禁止重定向,并配合出口/DNS/HTTPS 策略;字面量校验不能替代防火墙。 + +## Direct receiver / 直连部署 + +For an OTLP/HTTP Logs-compatible receiver, set these on the official server container; tracing may remain off: + +```dotenv +SKILLHUB_BUSINESS_LOGS_ENABLED=true +SKILLHUB_BUSINESS_LOGS_ENDPOINT=https://analytics.example.com/v1/logs +SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST=7,12 +SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST=skill.publication.effective,skill.download.prepared,skill.review.approved +``` + +Use the release Compose file plus deployment-controlled environment/Secret injection. Never commit real `HEADERS`; e.g. its format is `Authorization=Bearer%20` (encode comma, equals, percent and literal plus as needed; `+` decodes to space). Do not run `docker compose config` into shared logs with secrets loaded. Across trust boundaries require HTTPS and receiver-supported authentication. JVM default trusted certificates apply; for private CAs use a deployment-provided JVM truststore, not an insecure TLS bypass. mTLS can be terminated by a local Collector/proxy. / 通过部署侧 Secret 注入请求头;跨信任边界必须 HTTPS/鉴权,私有 CA 使用 JVM truststore;不要关闭 TLS 校验,不要公开渲染含密钥的 Compose 配置。 + +Docker Secret alternative (no credential in Compose interpolation): mount a Secret whose target filename is `skillhub.observability.business-logs.headers`, and set `SPRING_CONFIG_IMPORT=optional:configtree:/run/secrets/`. Spring config-tree keys override the lower-priority application default. Do not simultaneously define a nonempty `SKILLHUB_BUSINESS_LOGS_HEADERS`. Manage the actual Secret file outside the repository and protect access to Actuator/config diagnostics. / 可用 Docker Secret + Spring configtree,文件名即完整属性名;真实 Secret 文件放仓库外,不与非空环境变量重复设置。 + +Helm exposes `server.businessLogs.enabled`, `endpoint`, and `headersSecretRef.{name,key}`. The chart references an EXISTING Secret and does not create inline header secrets. Use existing `server.extraEnv` for every tuning/filter/Resource/Trace environment variable in the table. Do not duplicate the three built-in env names in extraEnv. / Helm 提供开关、目标及现有 Secret 引用;预算/过滤等使用 `server.extraEnv`,不要重复内建环境变量。 + +```yaml +server: + businessLogs: + enabled: true + endpoint: https://analytics.example.com/v1/logs + headersSecretRef: + name: business-logs-credentials + key: headers + extraEnv: + - name: SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST + value: "7,12" +``` + +## Optional Collector and real receive/query example / 可选 Collector 实收查询 + +The pinned example is `docs/examples/business-event-logs/compose.yaml` (Collector contrib `0.111.0`, 128 MiB / 0.5 CPU, only local HTTP port). It writes OTLP JSON to an ephemeral file, NOT production durable storage. Do not expose its unauthenticated port externally. It is separate from release Compose and is not started by default. / 示例仅本地验证,JSON 文件随容器删除,不是生产持久存储;无鉴权端口不得对外开放,不默认随正式部署启动。 + +From repository root, with port 4318 free: + +```bash +docker compose -f docs/examples/business-event-logs/compose.yaml up -d +# Host-run app: SKILLHUB_BUSINESS_LOGS_ENDPOINT=http://127.0.0.1:4318/v1/logs +# Docker Desktop server container: use host.docker.internal:4318 instead of localhost. +# Linux containers: use a shared private network / explicit host-gateway routing. +# Enable business Logs on the app; publish/approve or request an authorized published download. +docker cp skillhub-1009-collector:/business-logs.json ./business-logs.jsonl +jq -r '.resourceLogs[].scopeLogs[].logRecords[] | (.attributes | map({key:.key,value:(.value.stringValue // .value.intValue // .value.boolValue)}) | from_entries) as $a | [.timeUnixNano, $a["skillhub.event.name"], $a["skillhub.skill.id"], $a["log.record.uid"]] | @tsv' business-logs.jsonl +docker compose -f docs/examples/business-event-logs/compose.yaml down +``` + +For a source-verifiable run of the REAL production exporter wiring against the same Collector config, without bootstrapping business accounts: + +```bash +cd server +TESTCONTAINERS_RYUK_DISABLED=true ./mvnw -pl skillhub-app -am test \ + -Dtest=BusinessLogsCollectorTest -Dsurefire.failIfNoSpecifiedTests=false \ + -Dskillhub.test.evidence-dir=/absolute/operator-owned/evidence +``` + +Create that evidence directory first. The test uses named disposable containers and copies the actual sink output to `collector-received.jsonl`; apply the same jq query. It exercises all nine catalog records in all three Trace configurations (SDK trace sampling 0), checks IDs, UID uniqueness, field exclusion and receiver ACK metrics. This is real exporter/receiver integration, not a claim of a complete browser/CLI deployment smoke or loading an external Java agent. / 该测试使用真实应用导出装配及真实 Collector,保存实收结果,可用相同 jq 查询;不等同于完整 Web/CLI 部署验收或真实 Java Agent 注入测试。 + +Collector is optional. Production operators choose exporters, retention, TLS/auth, memory limiter and persistent sending queues appropriate to their sink. Collector persistence cannot recover records lost before application admission. File demo has no retention policy and must be stopped after verification. / 生产出口、保留策略、持久队列由运维选择;Collector 持久化不能补回应用侧漏报,示例用后停止。 + +## Reliability, metrics and troubleshooting / 可靠性、指标与排障 + +Business threads only map fixed bounded fields, capture context, register a transaction callback and attempt nonwaiting SDK admission. No DB enrichment or network IO is performed by the adapter on business threads. Standard BatchLogRecordProcessor handles a bounded record queue; a conservative admission permit counts queued PLUS in-flight records to expose exact queue-full drops without SDK internals. Only SDK exporters implement network retry (retryable HTTP status/IO); no caller-runs fallback. Batch export budget is `(timeout + retry-max-backoff) * retry-max-attempts + 1s`; receiver `Retry-After` and sender behavior still depend on the managed SDK. / 业务线程仅做固定字段映射及非等待入队,不发送网络请求;队列预算包含在途,重试交标准 SDK。 + +Loss is expected on commit-to-listener/admission gaps, crashes, forced exits, queue saturation, timeout or exhausted retry, and incomplete shutdown. No disk spool, outbox, replay, backfill or exactly-once. Ambiguous acknowledgement can duplicate the same UID; deduplicate by `log.record.uid`, not `(skill,version)` (multiple legitimate downloads), request ID or trace ID. Mapping a new domain-event dispatch creates a new UID; idempotency of repeated business operations is unchanged. / 提交到入队间隙、崩溃、满队列及重试耗尽都可能漏报;超时重试可能重复,接收方按 UID 去重,不按技能版本或 Trace 去重。 + +The queue budget starts at after-commit admission. Before commit, transaction synchronizations retain one bounded mapped snapshot per dispatched event until completion, outside that SDK budget. Avoid unbounded bulk work in one outer transaction; this configuration is not a process-wide heap cap. / 队列预算从提交后接纳开始;提交前每个事件的有界快照由事务回调保留,不计入 SDK 预算。不要在单个外层事务里执行无界批量操作,这不是整个进程的堆内存上限。 + +Graceful shutdown stops new admission, gives SDK drain a `shutdown-timeout` wait budget, then shuts down the exporter to cancel pending network work. Local cleanup and scheduling add some overhead; this is not a hard-real-time deadline. Remaining records may be lost; daemon completion may continue briefly. This differs deliberately from the draft RFC's discard-unsent-on-disable wording: configuration is NOT hot-reloaded; setting `enabled=false` takes effect on restart, whose old process gets a bounded drain opportunity. Already accepted receiver data cannot be recalled. / 正常退出停止接纳并限时等待排空,另有本地清理和调度开销,不是硬实时期限;剩余记录可丢失。关闭开关需重启,旧进程会限时发送,不承诺立即撤销已入队或接收的数据。 + +OTel 1.31 treats all HTTP 2xx responses as full success and does not expose `partial_success` rejected counts. `exported` means SDK/receiver ACK, NOT downstream storage confirmation. Do not replay whole partially accepted batches. Partial rejection diagnostics and durable retries belong at the receiver; this change does not upgrade the BOM. / 1.31 不读取部分拒收数量;成功指标不代表最终落库,不整批重发部分接收的数据。 + +Micrometer names on the existing protected Actuator metrics surface (Prometheus renders dots as underscores): + +| Metric | Unit / 含义 | +| --- | --- | +| `skillhub.business.logs.enqueued` | Records admitted / 已接纳记录 | +| `skillhub.business.logs.dropped{reason=queue_full}` | Records refused because outstanding budget is full / 满队列丢弃 | +| `skillhub.business.logs.dropped{reason=shutdown}` | New records refused during closing / 退出时拒绝新记录 | +| `skillhub.business.logs.errors` | Adapter mapping/admission/lifecycle exceptions, swallowed / 适配器异常(不传播) | +| `skillhub.business.logs.export.failures` | Batches whose SDK export fails/throws after retries / 最终失败批次 | +| `skillhub.business.logs.exported` | Records in SDK-successful batches; may include receiver partial rejections / SDK 成功记录,含部分拒收盲区 | + +No per-user/skill/namespace metric tags. Adapter diagnostics are fixed text, at most once per minute per pipeline, without exception messages, headers or event payloads; SDK diagnostics use its own throttling. / 指标不带高基数用户/资源标签,适配器诊断每分钟最多一次固定文字。 + +| Symptom / 现象 | Check / 排查 | +| --- | --- | +| Startup fails / 启动失败 | Enabled without endpoint, bad full URL, invalid bounds/allowlist/header encoding; do not paste config secrets / 检查目标、范围、编码,不分享 Secret | +| No records, no exporter metrics / 无记录或指标 | Default off? Environment explicitly forwarded? Container recreated? No historical backfill / 开关、透传、重建、新事件 | +| Enqueued but failed / 已入队但失败 | `/v1/logs`, HTTP/protobuf (not gRPC 4317), DNS/network/TLS/auth and receiver Logs pipeline / 路径、协议、网络及鉴权 | +| Drops / 丢弃 | Outstanding queue + in-flight, receiver throughput; tune bounded budgets, not an unbounded queue / 检查接收吞吐与在途占用,不改无界 | +| Missing one approval fact / 少一条批准事实 | Source vs target namespace allowlist; content vs metadata review; transaction rollback / 来源与目标空间过滤、内容与元数据审核区别、回滚 | +| Duplicate counts / 重复 | UID dedup, receiver ack ambiguity, agent bridges; never bridge ordinary app logs into this scope / UID 去重、确认歧义、Agent 日志桥接 | +| Exported count exceeds sink count / 指标大于入库量 | 2xx partial rejection blind spot, downstream filters/retention; inspect receiver / 部分拒收盲区及下游过滤 | + +Trace modes remain independent: `none`/`external-agent` still forbid the TRACE endpoint, not the business Logs endpoint. External agents must not bridge/re-export this dedicated scope or attach a second business Logs processor; verify real deployment agent plugins for duplicates. No actual external-agent installation is implied by application configuration tests. / Trace 限制仅针对 Trace 目标;部署方须检查 Agent 插件是否重复导出业务记录。 + +For executed verification and explicit remaining limits, see [acceptance matrix](../openspec/changes/add-otlp-business-event-logs/acceptance.md). diff --git a/docs/observability-developer-guide.md b/docs/observability-developer-guide.md index 405105edd..fae365a7e 100644 --- a/docs/observability-developer-guide.md +++ b/docs/observability-developer-guide.md @@ -18,15 +18,19 @@ 通过 `SKILLHUB_TRACING_MODE` 选择一种模式,修改后重启应用: -- `none`:默认模式。无应用内 OTel SDK 和 OTLP 导出,只保留 `request.id`。 +- `none`:默认 Trace 模式。无应用内 Trace SDK 和 Trace OTLP 导出,只保留 `request.id`;独立业务 Logs 开关不受此限制。 - `otel-sdk`:使用 Micrometer Tracing + OTel Bridge;配置 `MANAGEMENT_OTLP_TRACING_ENDPOINT` 后才向 Collector 导出。 - `external-agent`:应用内 Tracer 为 NOOP,由部署环境提供唯一的外部 Agent。 SkillHub 只能校验自身配置,不能识别任意 JVM Agent;唯一 Agent 是部署检查项。 -`none`/`external-agent` 不能配置 OTLP endpoint;`otel-sdk` 与外部 Tracing Agent +`none`/`external-agent` 不能配置 TRACE OTLP endpoint;`otel-sdk` 与外部 Tracing Agent 不得在同一进程中叠加。 +业务分析使用独立、默认关闭的 LoggerProvider,不桥接应用日志或更改 Trace 采样。 +生产点只增加领域快照,提交后才入队;事件与字段必须通过统一 mapper 白名单。 +开发/部署契约见[业务事件 Logs](./business-event-logs.md),不要把审计或可靠通知改接为有损遥测。 + ## 3. 开发者接入方式 ### 3.1 普通 HTTP 请求 diff --git a/docs/skillhub/en/guide/deployment.md b/docs/skillhub/en/guide/deployment.md index 275b3b92c..70b64a8cb 100644 --- a/docs/skillhub/en/guide/deployment.md +++ b/docs/skillhub/en/guide/deployment.md @@ -38,6 +38,16 @@ Resolve validation errors before starting. Backend startup runs Flyway database Suite review writes are enabled by default in the single-server release Compose topology; Suite Bundle confirmation is disabled by default. See [Bundle import](suite-bundle.md) for enabling it. For rolling upgrades where old and new backends coexist, follow the [Suite deployment order](https://github.com/iflytek/skillhub/blob/main/docs/25-skill-suites.md). +## Optional business-event OTLP Logs + +Business analytics is disabled by default and independent of all three Trace modes. With the matching +official server image, enable `SKILLHUB_BUSINESS_LOGS_ENABLED=true` and a full +`SKILLHUB_BUSINESS_LOGS_ENDPOINT`; Collector is optional and authentication headers belong in deployment +Secrets. See the [business-event operations guide](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md) +for publication, download preparation, review, promotion, archive, visibility and yank semantics, private-ID +scope, every setting, a real receive/query example and loss/duplicate/shutdown limits. Download preparation +is not completion or installation; this lossy stream is not audit, billing or reliable notification. + ## Upgrade, stop, and troubleshoot Before upgrading, record the current image version and configuration, and back up both the database and skill object storage. Set `SKILLHUB_VERSION` to the target release and repeat validation, `pull`, `up -d`, and workflow verification. Rolling back an image does not roll back the database; check migration compatibility first. diff --git a/docs/skillhub/guide/deployment.md b/docs/skillhub/guide/deployment.md index ae8f69703..4b17983ed 100644 --- a/docs/skillhub/guide/deployment.md +++ b/docs/skillhub/guide/deployment.md @@ -38,6 +38,15 @@ docker compose --env-file .env.release -f compose.release.yml logs --tail=100 se Suite 审核写入在单机 release Compose 中默认开启;Suite Bundle 确认默认关闭。需要批量导入时,按 [Bundle 导入](suite-bundle.md#部署开关)启用对应开关。滚动升级新旧后端共存时,另按[套装部署顺序](https://github.com/iflytek/skillhub/blob/main/docs/25-skill-suites.md#部署顺序)操作。 +## 可选的业务事件 OTLP Logs + +业务分析默认关闭,与三种 Trace 模式独立。对应版本官方镜像可通过 +`SKILLHUB_BUSINESS_LOGS_ENABLED=true` 和完整 `SKILLHUB_BUSINESS_LOGS_ENDPOINT` 启用; +Collector 可选,请求头凭证使用部署 Secret。发布、下载准备、审核、提升、归档、可见性和 yank 的 +精确定义、私有 ID 范围、完整配置表、实收查询示例及丢失/重复/退出限制见 +[业务事件运行手册](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md)。 +下载准备不是下载完成或安装成功,不能用于审计、计费或可靠通知。 + ## 升级、停止与排障 升级前保存当前镜像版本、配置,并备份数据库和技能对象存储。修改 `SKILLHUB_VERSION` 为目标发布版本,重新执行校验、`pull`、`up -d` 和业务验证。回退镜像不等于回退数据库;先检查目标迁移的兼容性。 diff --git a/openspec/changes/add-otlp-business-event-logs/acceptance.md b/openspec/changes/add-otlp-business-event-logs/acceptance.md new file mode 100644 index 000000000..bfac53fb7 --- /dev/null +++ b/openspec/changes/add-otlp-business-event-logs/acceptance.md @@ -0,0 +1,60 @@ +# Acceptance evidence — OTLP business-event Logs + +Base: `9ebb99226dd1b3e1d4fd8dd3e36baae70a4ed273`; branch `feat/otlp-business-events-1009`. These are executed results, not a claim of a deployed browser/CLI production stack. Raw logs and receiver output are retained in the implementation worker's evidence directory; the worker report gives absolute paths. / 以下为实际运行结果,不等同完整生产部署验收;原始输出路径见 worker report。 + +## Event/producer matrix + +`BusinessLogsProducerPostgresTest` uses actual Spring service proxies, real JPA repositories, Flyway migrations and PostgreSQL 16. Storage/scanner/avatar/notification/audit/compensation collaborators and package-validation/metadata-parsing collaborators are isolated mocks; business state and transaction boundaries are real. Each row has a committed scenario and a rollback scenario. Rollback emits zero records. / 服务代理、仓储、迁移和事务为真实依赖;存储、扫描、头像、通知、审计、补偿及包校验/解析协作组件用 mock 隔离,不宣称验证它们的端到端副作用。 + +| Producer path | Committed facts (exactly) | Rollback | Actor | +| --- | --- | --- | --- | +| Privileged auto-publication | publication effective | 0 | privileged submitting user | +| PRIVATE confirmation | publication effective | 0 | confirmer | +| Content review approval | publication effective + review approved | 0 | actual reviewer, not owner | +| Content review rejection | review rejected | 0 | actual reviewer | +| Initial promotion approval | target publication effective + source promotion approved | 0 | actual approver | +| Promotion update approval | target publication effective + source promotion approved | 0 | actual approver | +| Promotion rejection | source promotion rejected | 0 | actual reviewer | +| Archive | status changed ACTIVE → ARCHIVED | 0 | owner | +| Unarchive | status changed ARCHIVED → ACTIVE | 0 | owner | +| Immediate narrowing | visibility changed to PRIVATE | 0 | requester | +| Metadata-review expansion | visibility changed to PUBLIC, no new publication | 0 | expansion approver | +| Published version yank | version yanked | 0 | admin actor | +| Published download inside outer transaction | download prepared | 0 | anonymous: user type without user.id | + +Result: 39 real-service/JPA scenarios passed in `final-focused-3.log`: the 26 commit/rollback scenarios above plus 13 denied-actor cases, one for every path, asserting no exported fact and unchanged persisted skill/version/review/promotion state. Includes PRIVATE publication and review-task rollback assertions, actor checks and sensitive-field exclusion. Dedicated nontransactional real-download-service test separately passed: one anonymous preparation; no facts for review package or denied anonymous/unauthorized PRIVATE access. / 上表 26 个提交/回滚场景及逐路径 13 个拒绝授权场景通过;拒绝时不发事件、持久状态不变。无事务真实下载服务另验证一次匿名准备,以及审核包、匿名/无权限私有请求不发事件。 + +## Cross-cutting acceptance + +| Requirement | Executed assertion | Result / evidence | +| --- | --- | --- | +| TDD | New mapper/config tests compiled before implementation and failed on missing implementation symbols | RED `red-unit.log`; initial GREEN 12 tests in `green-unit-2.log` | +| Mapper boundary | Exact fixed keys; no rejection reason/submitter/unknown event serialization; namespace/event allowlists; anonymous omission; system actor; length limits; reserved built-in publisher | PASS `BusinessEventMapperTest` | +| Default off | No pipeline/listener/provider bean; no default receiver | PASS `BusinessLogsConfigurationTest` | +| Invalid configuration | Enabled+empty URL rejected; metadata/userinfo/query/file URL and bad budgets/event names rejected without echoing supplied URL; URL-encoded headers and control-character rejection | PASS `BusinessLogsConfigurationTest` | +| Capture before commit | Valid unsampled span survives scope end before PostgreSQL commit; implementation reads standard SpanContext only, not request/MDC strings | PASS `BusinessLogsTransactionTest`; mapper/listener source boundary | +| Trace coexistence | Actual production configuration exports 9 records each with none, otel-sdk (sampling=0), external-agent mode | PASS, actual Collector output 27 records / 9 names / 27 unique UIDs | +| Actual OTLP receive/query | Pinned Collector contrib 0.111.0 file exporter; documented jq query run against real received JSON | PASS `collector-received.jsonl`, `collector-query.tsv`, `collector-query-summary.json` | +| Queue full, deterministic | 1000 caller events with blocked exporter; exactly 999 drops; bounded shutdown | PASS final focused run: 12 ms caller loop, 169 ms close for 100 ms wait budget (includes scheduling/cleanup overhead) | +| Live receiver stalled | Pause real Collector; queue=2, batch=1, HTTP timeout=500ms; 1000 caller events; failures counted | PASS final focused run: 10 ms caller loop, 998 drops | +| Live receiver down | Stop real Collector; publishing returns without exception; export failure counter increments | PASS focused run: caller 0 ms | +| Duplicate semantics | HTTP receiver first responds 503 then 200; retry bytes identical and include the same UID; auth appears only in transport header | PASS `BusinessLogsHttpTest`, 2 requests / 1 SDK-exported record | +| Partial rejection blind spot | Real protobuf HTTP 200 partial_success rejected=1; no whole-batch replay; SDK reports success | PASS expected SDK limitation, 1 request / 1 SDK-success counter | +| Resource bounds | Java test fork capped at 512 MiB; Collector Docker inspect verifies 128 MiB and 0.5 CPU; at most 16 record attributes; string cap tested | PASS focused command with `-DargLine=-Xmx512m`; not a throughput/byte-accurate heap benchmark | +| Existing consumers | All affected domain service tests, 20 notification tests and 13 Trace configuration/filter tests | PASS final focused run: 289 total tests, no failures/errors/skips | +| Release config | Actual make target with a synthetic valid fixture (removed afterward); existing release shell regression suite | PASS `validate-release-config.log`, `release-config-tests.log`; no real credential file used | +| Helm/Compose | Helm 3.17.3 lint/render, existing chart contracts; new Python deployment contracts assert default-off, endpoint requirement, Secret reference and env forwarding | PASS 1 chart, 4 Python tests without skips (`business-logs-config-with-helm-final.log`), `helm-contracts.log` | +| Backend packaging | Reactor app+dependencies package with tests explicitly skipped (tests verified separately) | PASS `backend-package-final.log` | +| Local doc/source checks | Changed Markdown relative targets and code-fence pairing; required OpenSpec headings | PASS 11 files, 19 relative targets, 5 requirements / 9 scenarios (`docs-source-check.json`); not a site build or OpenSpec CLI run | + +The raw measurement values above are from the named focused run, not promised production latency percentiles. Shutdown has a bounded SDK wait budget plus local cleanup/scheduling overhead. / 延迟数字是该次测试实测,不是生产百分位承诺;退出等待预算之外有本地清理和调度开销。 + +## Broad suite and environment limits + +Broad `make test-backend` runs are NOT green. Initial candidate: 2732 tests, 2 failures, 1 error, 30 skips. Final candidate broad run: 2762 tests, 3 failures, 11 errors, 30 skips. Frozen base: 2712 tests, 2 failures, 2 errors, 30 skips. Failures are in GitHub integration tests; exact failing methods are retained in the count JSONs. Four methods occur in both the final candidate's and base's broad failure sets (Go executor, credential privacy, two budget cases), but the full failure sets and error modes vary. Do NOT infer that every candidate failure was proven pre-existing. Subsequent identical five-class GitHub focused commands PASS all 37 tests on BOTH the candidate and frozen base. An independent SQL/host clock probe found the Docker database clock ahead of the host in every sample, consistent with timing-sensitive eligibility checks, but it is NOT asserted as the proven cause of every failure. No clock/global configuration was changed. The authorized detached base worktree was removed. / 全量检查不是绿色;基线与 candidate 有四个相同失败方法,但集合及错误形式不同,不冒称全部故障已证实为基线问题。随后相同五类 GitHub 定向测试在两边均 37 项通过。独立探测发现 Docker 数据库时钟领先宿主机,与时间筛选类故障相符,但未证明所有故障根因;未改系统时钟/全局配置。基线临时工作树已移除。 + +The 13 added denial cases and two mapper edge assertions were finalized after the last broad run compiled its test classes; all final sources were then exercised by the successful 289-test focused run. / 最后一次全量编译测试类后补齐 13 项拒绝授权及两个 mapper 边界断言,最终源码由 289 项定向测试实际验证。 + +Docs site build was attempted. VitePress was absent; `npm ci` was blocked by the environment's network guard for both the lockfile registry and an explicit npmjs alternative. This is reported as BLOCKED, not passing. Markdown/source/link checks are separate from a built site. / 文档站构建受依赖下载网络限制,未冒称通过。 + +Not executed: real external Java-agent/plugin installation, TLS/mTLS production deployment, a browser/CLI deployment smoke, forced-process-crash recovery or sustained throughput/heap profiling. There is no outbox/replay guarantee to test. Agent duplicate suppression, authorized egress/DNS/redirect policy, production Secret provisioning, receiver persistence and retention remain deployment responsibilities. All application mode combinations were exercised without installing a Java agent. / 未执行真实 Agent 注入、生产 TLS/mTLS、完整 Web/CLI 部署或持续负载测试;相关限制和部署责任明确保留。 diff --git a/openspec/changes/add-otlp-business-event-logs/design.md b/openspec/changes/add-otlp-business-event-logs/design.md new file mode 100644 index 000000000..5a5ffd3e0 --- /dev/null +++ b/openspec/changes/add-otlp-business-event-logs/design.md @@ -0,0 +1,39 @@ +## Architecture + +Domain records retain their original accessors and compatibility constructors, and gain an immutable `BusinessEventSnapshot` (namespace ID, visibility, opaque actor/type, fixed source, Instant). Every production publisher of the nine included event types supplies this snapshot. No domain OTel dependency is introduced. Notification/search listeners keep their existing semantics. Missing legacy snapshots are ignored, not enriched asynchronously. The promotion rejection path adds one scalar-source lookup because its old event/request lacks source namespace/visibility; it does not export reasons or submitters. + +The conditional app configuration owns a dedicated `SdkLoggerProvider`, BatchLogRecordProcessor and OTLP/HTTP Logs exporter. It exposes only the pipeline/listener as beans, not a global/provider bean that Boot might consume for Trace autoconfiguration. Resource is explicitly constructed from existing service settings, not detected. No application logs are bridged; no trace sampling/configuration is altered. + +## Commit and context boundary + +A synchronous Spring `@EventListener` maps fixed scalar data and captures only valid OTel SpanContext when the producer dispatches. It registers `TransactionSynchronization.afterCommit` for actual transactions, otherwise admits immediately. This is equivalent to AFTER_COMMIT + fallbackExecution but preserves the producer's span even if the span ends before commit. Each boundary catches telemetry RuntimeExceptions and counts them with bounded fixed-text diagnostics. No network IO, enrichment queries or space-waiting occurs in the listener. Disabled configuration creates no listener, provider, worker, exporter or OTel context objects. + +Occurrence is `Instant.now()` immediately before producer publication, not the independently clocked persisted publishedAt or commit time. Sources identify domain branch, not caller-controlled Web/CLI labels. Forced internal auto-publication is a system actor; privileged human auto-publication remains a human actor. Anonymous downloads have actor type user and no user.id (user means user-request origin, not proof of an identified human). Review and promotion use the actual approver, not owner/uploader. Promotion decisions use source skill/namespace; effective publication uses the derived target. + +## Bounds and observability + +Standard SDK batching is retained. A nonwaiting semaphore around processor admission conservatively bounds queued PLUS in-flight records to `max-queue-size`; SDK queue capacity is at least that large, so accepted records cannot hit its hidden overflow boundary. A measured exporter releases permits when the SDK result completes and counts exported records or failed batches. This supplies exact admission/full metrics without reflection or a custom network queue/retry engine. SDK MeterProvider is explicitly noop; exporter health uses the existing Micrometer registry without global registration. + +A single mapper allows only known event classes, fixed sources/actor types, scalar IDs, enums and bounded user ID. The SDK also limits 16 attributes and 256 code units. Three Resource strings are separately bounded. Configuration has hard count/duration/header/list bounds. Admitted pipeline memory is bounded by record count and fixed record shape, not a promised byte-accurate or zero-allocation budget. Before commit, transaction synchronizations retain one bounded mapped snapshot per dispatched event outside the SDK admission window; callers must not wrap unbounded bulk work in one transaction. This is not a process-wide heap cap. Saturation latency and receiver container limits are measured in tests; this is not a production throughput benchmark. + +## SDK compatibility verified against 1.31.0 + +Boot remains 3.2.3, OTel remains BOM-managed 1.31.0. Only an explicit managed sdk-logs dependency is added. EventName API is absent: use custom `skillhub.event.name`. No global semconv schema is advertised; scope contract version is 1 and experimental log.record.uid semantics are documented locally. + +The public HTTP Logs exporter builder supports RetryPolicy; set explicit finite attempts/backoff, or leave retry disabled for attempts=1. No retry code is implemented here. Logs endpoint is explicit and used as-is. Builders do not read OTEL_* automatically. The SDK treats 2xx as success without inspecting partial-success counts; counters reflect that limitation and tests send a real protobuf partial-success response. Retry tests verify identical payload/UID and transport-only headers. + +The managed OkHttp sender uses its default redirect policy; the public 1.31 Logs builder has no redirect-policy customization. Do not fork the sender, use reflection or introduce an unrelated HTTP dependency: document nonredirecting receivers and enforce authorized destinations/TLS with deployment egress policy. This is an explicit limit relative to the draft RFC's redirect-validation aspiration, not a claim of full in-process SSRF prevention. Its retry interceptor bounds jittered backoff and does not honor Retry-After; SDK timeout/attempt limits may terminate before the configured maximum attempts. + +## Data and destination policy + +Private resource IDs are included by default only after instance operators opt in; empty namespace allowlist means all namespaces. No names/slugs/content are exported, avoiding entity serialization/enrichment and keeping governance facts available. IDs are linkable data, not anonymized. Namespace and exact event allowlists are startup-only. Credentials are deployment headers/Secrets only; endpoint userinfo/query/fragment and known metadata literal destinations are rejected without echoing input. Localhost/private receivers are allowed. Operators still own egress/DNS policy, TLS/truststore, retention and receiver access. Literal URL validation cannot prevent all metadata aliases/rebinding. + +## Reliability and RFC deviations + +Best-effort analytics only; commit-to-admission gaps, crashes, overflow, timeout, shutdown and receiver failures can lose data. No outbox/replay/exactly-once. Same SDK retry reuses mapped UID; a new domain dispatch gets a new UID. Approval and publication are intentionally distinct facts. + +No runtime hot-reload. On restart to disable, the old process gets a bounded graceful drain opportunity rather than the RFC draft's immediate unsent cancellation. New admission stops on close; provider shutdown is joined for shutdown-timeout and the exporter is canceled at deadline. Accepted receiver data cannot be withdrawn. An active external-agent JVM/plugin installation, TLS/mTLS deployment and browser/CLI end-to-end smoke are separate deployment verifications, not inferred from application mode tests. + +## Scope + +All nine existing facts are included. Metadata/avatar review decisions with no existing decision event, hide/unhide, promotion revocation, submission/withdrawal, Suite events, search/view, transfer completion, installation, durable delivery and historical backfill are deferred. Existing metadata-review effective visibility events ARE included. The event catalog and full operator configuration live in docs/business-event-logs.md; executed results and limitations live in acceptance.md and the worker report. diff --git a/openspec/changes/add-otlp-business-event-logs/proposal.md b/openspec/changes/add-otlp-business-event-logs/proposal.md new file mode 100644 index 000000000..3a9531fac --- /dev/null +++ b/openspec/changes/add-otlp-business-event-logs/proposal.md @@ -0,0 +1,23 @@ +## Why + +Self-hosted deployments need vendor-neutral business analytics using the official image and deployment configuration, without a fork or a bespoke webhook protocol. Issue #1009 and maintainer comment 6463066865 authorize existing domain events → dedicated OpenTelemetry Logs → OTLP → operator receiver. + +## What Changes + +- Add a default-off, independent OTLP/HTTP protobuf business Logs adapter in app. +- Snapshot occurrence time, opaque actor, namespace ID and visibility at existing domain producers; keep notification/search semantics and permissions unchanged. +- Include publication (all four producers), published-download preparation, content-review decisions, promotion decisions, archive/unarchive, effective visibility change and version yank. +- Bound admission, attributes, retry, timeout and shutdown; expose Micrometer analytics-export health counters. +- Document event semantics, deployment, data boundary, real receiver/query example and acceptance evidence alongside implementation. + +## Capabilities + +### New Capabilities +- `business-event-logs`: operator-controlled, lossy business analytics using OTLP Logs. + +### Modified Capabilities +None. No API, package, CLI, storage schema, authorization or audit contract changes. + +## Scope and deferrals + +Not audit, billing, notification, outbox, replay, historical backfill or exactly-once delivery. No application-log bridge, new trace pipeline, vendor SDK or BOM upgrade. Metadata/avatar review decisions without existing decision events, hide/unhide, promotion revocation, review submission/withdrawal, Suite events, search/views, stream completion and installation are deferred. Effective metadata-review visibility changes ARE included through their existing visibility event. No rejection comments, content, names/slugs, network identifiers or request headers are exported. diff --git a/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md b/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md new file mode 100644 index 000000000..dc9aa3f7c --- /dev/null +++ b/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: Business Logs SHALL be opt-in and independent of Trace + +The application SHALL create a dedicated LoggerProvider only when `skillhub.observability.business-logs.enabled=true`. It SHALL NOT register global OpenTelemetry, bridge application logs, change trace sampling or start a trace exporter. Empty endpoint while enabled SHALL fail startup without disclosing configuration values. + +#### Scenario: Default deployment +- **WHEN** no business Logs settings are provided +- **THEN** no business LoggerProvider, processor, exporter, worker or listener is created + +#### Scenario: Trace independence +- **WHEN** business Logs is enabled with tracing `none`, `otel-sdk` or `external-agent` +- **THEN** business records are accepted independently of trace sampling +- **AND** only valid current span context is associated; request ID/MDC is never converted into trace identity + +### Requirement: Existing business facts SHALL retain producer meaning + +The event catalog in `docs/business-event-logs.md` SHALL define all included events, producer paths, occurrence boundary, actor and fields. Publication SHALL cover auto-publication, PRIVATE confirmation, review approval and promotion approval. Download preparation SHALL NOT mean transfer, installation or execution success. + +#### Scenario: Commit and rollback +- **WHEN** a supported event is published within a transaction +- **THEN** the adapter captures a scalar snapshot and trace context before handoff +- **AND** export admission occurs only after commit; rollback emits no record + +#### Scenario: Nontransactional download +- **WHEN** an authorized PUBLISHED download successfully builds its result without an outer transaction +- **THEN** one download-prepared record is admitted +- **AND** a rejected request, preview/review package, Suite plan or metadata poll emits no such record + +### Requirement: Export SHALL have bounded, nonblocking business-thread cost + +The adapter SHALL use asynchronous SDK batching and standard bounded exporter retry, never caller-runs network IO. Record count, batch size, attribute count/length, timeout, retry attempts/backoff and shutdown wait SHALL be bounded. Telemetry exceptions SHALL NOT propagate into business calls; failures SHALL be counted with bounded diagnostic logging. + +#### Scenario: Receiver outage and saturation +- **WHEN** the receiver stalls or fails and capacity is exhausted +- **THEN** business calls do not wait for receiver IO or queue space +- **AND** admission drops and failed exports are observable through existing Actuator metrics + +#### Scenario: Ambiguous acknowledgement +- **WHEN** an accepted batch is retried after an ambiguous network failure +- **THEN** the same `log.record.uid` survives retries +- **AND** receivers may deduplicate, without an exactly-once promise + +### Requirement: Data export SHALL use a single explicit allowlist + +A single mapper SHALL export only fixed documented scalar fields, including IDs of private resources. Namespace IDs and event names SHALL be operator-filterable. Human actors SHALL use only local opaque `user.id`; anonymous/unknown and system actors SHALL omit it. No entity serialization, enrichment queries, resource autodetection, content, credentials, URLs, IP/UA, request IDs or raw headers is permitted. + +#### Scenario: Sensitive rejection fields +- **WHEN** a domain rejection carries arbitrary reason or submitter information +- **THEN** neither field is copied into business attributes or body + +#### Scenario: Private resource scope +- **WHEN** a private resource is within the configured namespace/event allowlists +- **THEN** only IDs, visibility and documented operational fields are exported +- **AND** names, slugs and content remain excluded + +### Requirement: Deployment and delivery limitations SHALL be explicit + +User documentation SHALL include every configuration key/default/bound, direct and Collector deployment, Secret injection, loss/duplicate/shutdown semantics, SDK compatibility limits, troubleshooting, a reproducible real receiver/query example and an acceptance matrix separating executed from unverified scenarios. + +#### Scenario: Graceful exit +- **WHEN** the process shuts down +- **THEN** new admission stops and queued records get only the configured bounded drain opportunity +- **AND** remaining records may be lost; already accepted receiver data cannot be recalled diff --git a/openspec/changes/add-otlp-business-event-logs/tasks.md b/openspec/changes/add-otlp-business-event-logs/tasks.md new file mode 100644 index 000000000..c38dc9706 --- /dev/null +++ b/openspec/changes/add-otlp-business-event-logs/tasks.md @@ -0,0 +1,26 @@ +## Implementation + +- [x] Inventory every existing producer/consumer for the nine included event types; preserve notification/audit/permission behavior. +- [x] Add scalar producer snapshots (time, namespace ID, visibility, actor/type, fixed source), without domain telemetry dependencies. +- [x] Add a dedicated default-off Logs pipeline; no global registration, application-log bridge or second Trace pipeline. +- [x] Capture valid span identity before handoff; admit only after commit with nontransactional fallback. +- [x] Central allowlist mapper, private-ID policy, stable per-record UUID, fixed attribute/string limits. +- [x] Standard SDK batching/retry, bounded outstanding admission, timeout and shutdown; fixed-cardinality Micrometer counters and rate-bounded safe diagnostics. +- [x] Explicit application/release environment mappings, release validator, Helm defaults and existing-Secret reference; no new vendor/third-party dependency or BOM upgrade. +- [x] Bilingual operator documentation, deployment links, event catalog, configuration table, direct/Collector and Secret guidance. + +## Verification + +- [x] Write initial mapper/configuration tests before implementation; retain RED compiler output and subsequent GREEN output. +- [x] Execute mapper/filter/anonymous/system/UID/config validation tests. +- [x] Execute PostgreSQL transaction listener commit/rollback tests for all nine catalog facts, nontransactional real download/permission tests and unsampled context capture. +- [x] Execute SDK HTTP retry/identical-payload UID/auth-only tests and partial-success blind-spot test. +- [x] Execute real Collector receive-and-query with all nine events and three Trace configurations. +- [x] Execute real-service/JPA commit/rollback and denied-actor matrix for all producer paths (39 scenarios). +- [x] Execute paused/stopped live Collector saturation/outage and record measured latency/resource limits. +- [x] Execute synthetic release validation, existing release regression checks, Helm lint/render/contracts and new deployment configuration tests. +- [x] Finish broad backend/base runs and report reproduced versus unreproduced failures explicitly (full suite remains FAIL; identical GitHub focused checks pass on both). +- [x] Run final focused checks after the last code edits, query actual receiver evidence with documented jq command, validate local docs/links and diff (site build BLOCKED by dependency-network guard). +- [x] Verify cleanup of every owned temporary container/network/volume and the authorized baseline worktree; leave changes uncommitted. + +See acceptance.md for final executed results and explicit unverified deployment scenarios; a task checkbox is not itself acceptance evidence. From 74e2bc1457bcb1046036ed1aee185b92e9751ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= <258399167+xiaoyaner0201@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:41:45 +0800 Subject: [PATCH 05/21] fix(observability): bound business log cleanup and suppress unsafe SDK diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com> --- docs/business-event-logs.md | 12 +- .../acceptance.md | 20 ++- .../add-otlp-business-event-logs/design.md | 6 +- .../specs/business-event-logs/spec.md | 25 +++- .../add-otlp-business-event-logs/tasks.md | 10 ++ .../business/BusinessLogsPipeline.java | 42 +++++-- .../src/main/resources/logback-spring.xml | 6 + .../business/BusinessLogsDiagnosticsTest.java | 117 ++++++++++++++++++ .../business/BusinessLogsHttpTest.java | 39 ++++++ .../business/BusinessLogsPipelineTest.java | 68 ++++++++++ .../business/BusinessLogsTestReceiver.java | 73 +++++++++++ .../business/BusinessLogsTransactionTest.java | 88 ++++++++++++- 12 files changed, 489 insertions(+), 17 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsPipelineTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTestReceiver.java diff --git a/docs/business-event-logs.md b/docs/business-event-logs.md index bb2ff56c6..95cf43c5f 100644 --- a/docs/business-event-logs.md +++ b/docs/business-event-logs.md @@ -20,7 +20,9 @@ Every row requires `skillhub.event.name`, `skillhub.skill.id`, `skillhub.namespa Publication covers all four producers: `SkillPublishService`, `SkillReviewSubmitService`, `ReviewService`, `PromotionService`. The reserved `builtin-skill-publisher` identity is classified as `system` even through the ordinary privileged auto-publication path (bootstrap verifies that it is a system account). Approval may correctly create TWO different facts: publication plus review/promotion approval. These are not duplicate records. Promotion approval's publication references the TARGET skill/version/namespace; its promotion fact references the SOURCE. Namespace filtering may therefore keep only one. / 发布覆盖四条生产路径;内置技能的保留系统身份即便走普通特权直发,也标记为 system,启动器会校验其系统账号属性;批准同时产生发布及审核/提升两个不同事实,不是重复。提升的发布事件指目标,提升决定事件指来源,空间过滤可能只保留其一。 -Timestamp is captured immediately before the producer publishes its domain event, not `publishedAt`, transaction commit time or async-send time. Transaction synchronization captures the record and valid span identity at event dispatch, admits only after commit, and never emits on rollback. Outside a transaction (notably the shared download service), admission is immediate. No entity lookup happens in the telemetry listener. / 时间是生产点发布领域事件前的时刻,不是提交或发送时刻;事务内仅提交后入队,回滚不发;无事务下载直接入队。 +Timestamp is captured immediately before the producer publishes its domain event, not `publishedAt`, transaction commit time or async-send time. Transaction synchronization captures the record and valid span identity at event dispatch, admits only after the enclosing physical transaction commits, and suppresses records when that physical transaction rolls back. Outside a transaction (notably the shared download service), admission is immediate. No entity lookup happens in the telemetry listener. / 时间是生产点发布领域事件前的时刻,不是提交或发送时刻;事务内仅物理事务提交后入队,物理事务回滚不发;无事务下载直接入队。 + +Savepoint exclusion: rollback suppression does NOT cover `PROPAGATION_NESTED` savepoint rollback. Its synchronization belongs to the enclosing physical transaction: a nested event can still export when the outer transaction commits, even though the nested database write was rolled back. Do not use this pipeline as evidence of savepoint-level persistence; producers requiring that guarantee must not dispatch from nested/savepoint work. No current included production path configures NESTED propagation. `REQUIRES_NEW` is a separate physical transaction: its committed event exports even if the suspended outer transaction later rolls back; the outer event is suppressed. / 保存点例外:不保证 `PROPAGATION_NESTED` 回滚时抑制事件;外层物理事务提交仍可导出已回滚保存点内的事件。需要该保证的生产点不得在保存点内发布;当前纳入路径未配置 NESTED。`REQUIRES_NEW` 独立提交不受外层之后回滚影响。 Download means prepared, NOT delivered, complete, installed or executed. Web/v1/CLI share the producer; authorized previews of unpublished versions and review packages do not emit. Compat redirects alone, Suite plans, resolve/detail polling, direct object-storage access, URL reuse and client caches are not new registry download-prepared facts. / 下载准备不等于送达、传输完成、安装或执行;未发布预览、审核包、套装计划、查询轮询不计入。对象存储直取、URL 重用及缓存可绕过注册表。 @@ -127,6 +129,8 @@ Collector is optional. Production operators choose exporters, retention, TLS/aut Business threads only map fixed bounded fields, capture context, register a transaction callback and attempt nonwaiting SDK admission. No DB enrichment or network IO is performed by the adapter on business threads. Standard BatchLogRecordProcessor handles a bounded record queue; a conservative admission permit counts queued PLUS in-flight records to expose exact queue-full drops without SDK internals. Only SDK exporters implement network retry (retryable HTTP status/IO); no caller-runs fallback. Batch export budget is `(timeout + retry-max-backoff) * retry-max-attempts + 1s`; receiver `Retry-After` and sender behavior still depend on the managed SDK. / 业务线程仅做固定字段映射及非等待入队,不发送网络请求;队列预算包含在途,重试交标准 SDK。 +Each export has an owned completion guard: SDK completion OR the export-budget deadline, whichever wins, releases admission permits exactly once. An abandoned SDK result (including 1.31's truncated non-2xx response-body callback failure) becomes one failed batch at the deadline. Late SDK success/failure cannot add permits or change counters. Deadlines run on one daemon scheduler, canceled tasks are removed, and closing fails outstanding guards and stops the scheduler. This bounds local accounting, not remote cancellation or delivery: a timed-out request might still reach the receiver. / 每批导出由 SDK 完成或预算到期中先发生者结束,许可只释放一次;SDK 遗失完成信号也会计一次失败,迟到结果不重复释放或计数。关闭时结束守卫并停止调度器;本地超时不代表远端已撤销。 + Loss is expected on commit-to-listener/admission gaps, crashes, forced exits, queue saturation, timeout or exhausted retry, and incomplete shutdown. No disk spool, outbox, replay, backfill or exactly-once. Ambiguous acknowledgement can duplicate the same UID; deduplicate by `log.record.uid`, not `(skill,version)` (multiple legitimate downloads), request ID or trace ID. Mapping a new domain-event dispatch creates a new UID; idempotency of repeated business operations is unchanged. / 提交到入队间隙、崩溃、满队列及重试耗尽都可能漏报;超时重试可能重复,接收方按 UID 去重,不按技能版本或 Trace 去重。 The queue budget starts at after-commit admission. Before commit, transaction synchronizations retain one bounded mapped snapshot per dispatched event until completion, outside that SDK budget. Avoid unbounded bulk work in one outer transaction; this configuration is not a process-wide heap cap. / 队列预算从提交后接纳开始;提交前每个事件的有界快照由事务回调保留,不计入 SDK 预算。不要在单个外层事务里执行无界批量操作,这不是整个进程的堆内存上限。 @@ -143,10 +147,12 @@ Micrometer names on the existing protected Actuator metrics surface (Prometheus | `skillhub.business.logs.dropped{reason=queue_full}` | Records refused because outstanding budget is full / 满队列丢弃 | | `skillhub.business.logs.dropped{reason=shutdown}` | New records refused during closing / 退出时拒绝新记录 | | `skillhub.business.logs.errors` | Adapter mapping/admission/lifecycle exceptions, swallowed / 适配器异常(不传播) | -| `skillhub.business.logs.export.failures` | Batches whose SDK export fails/throws after retries / 最终失败批次 | +| `skillhub.business.logs.export.failures` | Batches whose SDK export fails/throws, exceeds the export budget, or is still outstanding at close / 最终失败、超时或关闭时仍未结束的批次 | | `skillhub.business.logs.exported` | Records in SDK-successful batches; may include receiver partial rejections / SDK 成功记录,含部分拒收盲区 | -No per-user/skill/namespace metric tags. Adapter diagnostics are fixed text, at most once per minute per pipeline, without exception messages, headers or event payloads; SDK diagnostics use its own throttling. / 指标不带高基数用户/资源标签,适配器诊断每分钟最多一次固定文字。 +No per-user/skill/namespace metric tags. Adapter diagnostics are fixed text, at most once per minute per pipeline, without exception messages, headers or event payloads: `Business Logs telemetry failed; inspect skillhub.business.logs metrics and receiver health`. Shipped `logback-spring.xml` sets `io.opentelemetry.exporter` and `io.opentelemetry.sdk.logs` to OFF by default, including their child loggers routed from JUL by Spring Boot. Raw SDK diagnostics can echo receiver error bodies or transport exception text containing credentials; throttling or ERROR level alone is NOT safe. This also silences shared OTel exporter diagnostics for Trace; Trace export behavior is unchanged. Custom logging configurations must retain these OFF rules. / 指标无高基数标签;适配器每分钟最多一次固定文字。默认关闭 OTel exporter 与 sdk.logs 原始诊断(包括子记录器及 JUL 桥接),其响应或异常文本可能回显凭证,ERROR 级别也不安全;同时静默共享 Trace exporter 诊断但不改变导出。自定义日志配置需保留这些规则。 + +Troubleshoot using the protected Actuator counters above, this fixed warning, receiver-side status/health, and independent DNS/network/TLS/auth checks with secret-safe tools. If raw SDK diagnostics are indispensable, first remove production credentials or reproduce with synthetic credentials in an isolated environment, restrict log access/retention, and knowingly accept disclosure risk. Temporarily set the exact Spring property `logging.level.io.opentelemetry.exporter.internal.http.HttpExporter=DEBUG` (e.g. a deployment-owned config file or `SPRING_APPLICATION_JSON`); `logging.level.io.opentelemetry.sdk.logs=DEBUG` opts into Logs processor diagnostics separately. Logger names are case-sensitive; do not rely on relaxed uppercase env names for the class logger. Restore both package defaults to OFF and remove any child-logger override after diagnosis, then restart; do not share unredacted SDK output. Raising root logging alone does not override explicit package OFF. / 排障优先使用受保护指标、固定告警及接收端健康,不输出 Secret。确需 SDK 原始细节时,在隔离环境用假凭证复现,限制访问与保留并明确接受泄露风险;临时提高精确日志属性(类名区分大小写),结束后恢复 OFF、删除子记录器覆盖并重启,勿分享未脱敏输出。 | Symptom / 现象 | Check / 排查 | | --- | --- | diff --git a/openspec/changes/add-otlp-business-event-logs/acceptance.md b/openspec/changes/add-otlp-business-event-logs/acceptance.md index bfac53fb7..798a62085 100644 --- a/openspec/changes/add-otlp-business-event-logs/acceptance.md +++ b/openspec/changes/add-otlp-business-event-logs/acceptance.md @@ -4,7 +4,7 @@ Base: `9ebb99226dd1b3e1d4fd8dd3e36baae70a4ed273`; branch `feat/otlp-business-eve ## Event/producer matrix -`BusinessLogsProducerPostgresTest` uses actual Spring service proxies, real JPA repositories, Flyway migrations and PostgreSQL 16. Storage/scanner/avatar/notification/audit/compensation collaborators and package-validation/metadata-parsing collaborators are isolated mocks; business state and transaction boundaries are real. Each row has a committed scenario and a rollback scenario. Rollback emits zero records. / 服务代理、仓储、迁移和事务为真实依赖;存储、扫描、头像、通知、审计、补偿及包校验/解析协作组件用 mock 隔离,不宣称验证它们的端到端副作用。 +`BusinessLogsProducerPostgresTest` uses actual Spring service proxies, real JPA repositories, Flyway migrations and PostgreSQL 16. Storage/scanner/avatar/notification/audit/compensation collaborators and package-validation/metadata-parsing collaborators are isolated mocks; business state and transaction boundaries are real. Each row has a committed scenario and a physical-transaction rollback scenario. Physical rollback emits zero records; `PROPAGATION_NESTED` savepoint rollback is excluded (it can export after outer commit). `REQUIRES_NEW` commit exports independently of outer rollback. / 服务代理、仓储、迁移和物理事务为真实依赖;物理事务回滚不导出,但保存点回滚明确不保证抑制;独立新事务提交不受外层回滚影响。外部协作组件用 mock 隔离,不宣称验证它们的端到端副作用。 | Producer path | Committed facts (exactly) | Rollback | Actor | | --- | --- | --- | --- | @@ -32,7 +32,7 @@ Result: 39 real-service/JPA scenarios passed in `final-focused-3.log`: the 26 co | Mapper boundary | Exact fixed keys; no rejection reason/submitter/unknown event serialization; namespace/event allowlists; anonymous omission; system actor; length limits; reserved built-in publisher | PASS `BusinessEventMapperTest` | | Default off | No pipeline/listener/provider bean; no default receiver | PASS `BusinessLogsConfigurationTest` | | Invalid configuration | Enabled+empty URL rejected; metadata/userinfo/query/file URL and bad budgets/event names rejected without echoing supplied URL; URL-encoded headers and control-character rejection | PASS `BusinessLogsConfigurationTest` | -| Capture before commit | Valid unsampled span survives scope end before PostgreSQL commit; implementation reads standard SpanContext only, not request/MDC strings | PASS `BusinessLogsTransactionTest`; mapper/listener source boundary | +| Capture before commit | Valid unsampled span survives scope end before PostgreSQL commit; Repair 1 additionally seeds distinct MDC/Baggage secrets and checks exported attributes/body/resource | PASS `BusinessLogsTransactionTest`; see Repair 1 evidence below | | Trace coexistence | Actual production configuration exports 9 records each with none, otel-sdk (sampling=0), external-agent mode | PASS, actual Collector output 27 records / 9 names / 27 unique UIDs | | Actual OTLP receive/query | Pinned Collector contrib 0.111.0 file exporter; documented jq query run against real received JSON | PASS `collector-received.jsonl`, `collector-query.tsv`, `collector-query-summary.json` | | Queue full, deterministic | 1000 caller events with blocked exporter; exactly 999 drops; bounded shutdown | PASS final focused run: 12 ms caller loop, 169 ms close for 100 ms wait budget (includes scheduling/cleanup overhead) | @@ -49,6 +49,22 @@ Result: 39 real-service/JPA scenarios passed in `final-focused-3.log`: the 26 co The raw measurement values above are from the named focused run, not promised production latency percentiles. Shutdown has a bounded SDK wait budget plus local cleanup/scheduling overhead. / 延迟数字是该次测试实测,不是生产百分位承诺;退出等待预算之外有本地清理和调度开销。 +## Repair 1 — independent review RETURN + +Repair base: `894e5860ce19059a27dab8cf94642aba56d33a29`; changes remain uncommitted. Evidence filenames in this section are relative to `evidence/repair1/` in the worker report, not the earlier evidence directory. The original results above remain historical; the current focused result is **299 tests, 0 failures, 0 errors, 0 skips** (193 domain + 106 app; 73 business-Logs tests). The same full focused selector/environment was rerun, including the new `Business*Test` classes. / 本轮在独立评审退回后修复,未提交;本节路径属于 repair1 子目录。之前结果保留为历史,当前定向结果为 299 项全部通过,其中业务日志 73 项。 + +| Finding / boundary | Executed assertion and change | RED → GREEN evidence | +| --- | --- | --- | +| Abandoned SDK result / permit leak | Production exporter receives HTTP 400, declares 100 body bytes but sends one and closes; export-budget guard fails once, then two HTTP 200 exports succeed. Requests=3, enqueued=3, exported=2, failures=1, drops=0; no restart | RED `red-permit-http.log` (1 test, failure-counter await times out at zero); GREEN `green-repair-focused.log` and `final-focused-green.log`, `BusinessLogsHttpTest` | +| Exactly-once cleanup | Deadline wins over an unfinished result; a second export occupies the recovered capacity; late SDK success OR failure cannot admit a third record or alter the failed outcome. Two parameter cases pass | RED `red-guard-diagnostics-boundaries.log` (2 guard timeouts); GREEN `final-focused-green.log`, `BusinessLogsPipelineTest` | +| SDK credential disclosure | Real 401 protobuf Status echoes synthetic Authorization; real malformed HTTP status line produces a credential-bearing ProtocolException. Shipped Logback package OFF rules suppress raw SDK output; fixed warning + failure counter remain. Captured stdout/stderr checked in BOTH text and async JSON modes; no synthetic credential | RED `red-guard-diagnostics-boundaries.log` (2 log assertions fail with credential output); GREEN `final-focused-green.log`, 4 `BusinessLogsDiagnosticsTest` cases | +| Savepoint contract | Explicitly exclude `PROPAGATION_NESTED` rollback suppression in operator guide/design/spec/acceptance. Real PostgreSQL: nested rollback + outer commit leaves 0 rows but exports 1 record; `REQUIRES_NEW` commit + outer rollback leaves only the independent row and exports only its record. A documentation consistency test covers all four contract files | RED `red-guard-diagnostics-boundaries.log` (documentation assertion fails; both runtime boundary tests already pass); GREEN `final-focused-green.log`, `BusinessLogsTransactionTest` | +| Non-vacuous MDC/Baggage exclusion | Assert distinct sensitive keys/values are actually present at dispatch, retain unsampled span across scope end, then assert neither keys nor values in exported attributes, body or resource | RED `red-guard-diagnostics-boundaries.log` (fixture precondition fails on missing MDC sentinel, not a product disclosure); GREEN `final-focused-green.log`, `BusinessLogsTransactionTest` | +| Deployment regression | New deployment contracts 4/4, no skips; existing Helm configuration and release validation regression suites both pass; no Helm/Compose setting changed | `deployment-business-logs.log`, `deployment-helm-contracts.log`, `deployment-release-contracts.log` | +| Live receiver regression | Real pinned Collector receives 27 records / 9 names / 27 distinct UIDs again. Paused receiver: 1000 caller events in 11 ms, 998 drops; stopped receiver: caller 0 ms, one failure. Blocked exporter: 12 ms caller loop, 999 drops, close 216 ms for 100 ms wait budget plus overhead | `final-focused-green.log`, current `collector-received.jsonl`; recorded values are observations, not SLOs | + +The first full rerun (`final-focused.log`) executed 299 tests but had 4 diagnostic-test setup errors: querying an uncreated Logback child returned null. The fixture now creates the child before checking its inherited OFF level; the subsequent complete rerun above is green. `green-repair-focused.log` is an earlier 15-test pass before extending diagnostics to both log formats; it is not the final count. No broad-backend/base comparison or docs-site build was rerun in Repair 1; the limitations below are unchanged. / 首次全定向检查中诊断测试的子 logger 尚未创建,4 项 setup 报错;修正测试 fixture 后完整重跑通过。不将中间失败或早期 15 项绿色记录冒充最终结果;本轮未重跑全量后端/基线对比或文档站构建。 + ## Broad suite and environment limits Broad `make test-backend` runs are NOT green. Initial candidate: 2732 tests, 2 failures, 1 error, 30 skips. Final candidate broad run: 2762 tests, 3 failures, 11 errors, 30 skips. Frozen base: 2712 tests, 2 failures, 2 errors, 30 skips. Failures are in GitHub integration tests; exact failing methods are retained in the count JSONs. Four methods occur in both the final candidate's and base's broad failure sets (Go executor, credential privacy, two budget cases), but the full failure sets and error modes vary. Do NOT infer that every candidate failure was proven pre-existing. Subsequent identical five-class GitHub focused commands PASS all 37 tests on BOTH the candidate and frozen base. An independent SQL/host clock probe found the Docker database clock ahead of the host in every sample, consistent with timing-sensitive eligibility checks, but it is NOT asserted as the proven cause of every failure. No clock/global configuration was changed. The authorized detached base worktree was removed. / 全量检查不是绿色;基线与 candidate 有四个相同失败方法,但集合及错误形式不同,不冒称全部故障已证实为基线问题。随后相同五类 GitHub 定向测试在两边均 37 项通过。独立探测发现 Docker 数据库时钟领先宿主机,与时间筛选类故障相符,但未证明所有故障根因;未改系统时钟/全局配置。基线临时工作树已移除。 diff --git a/openspec/changes/add-otlp-business-event-logs/design.md b/openspec/changes/add-otlp-business-event-logs/design.md index 5a5ffd3e0..b20789074 100644 --- a/openspec/changes/add-otlp-business-event-logs/design.md +++ b/openspec/changes/add-otlp-business-event-logs/design.md @@ -8,11 +8,15 @@ The conditional app configuration owns a dedicated `SdkLoggerProvider`, BatchLog A synchronous Spring `@EventListener` maps fixed scalar data and captures only valid OTel SpanContext when the producer dispatches. It registers `TransactionSynchronization.afterCommit` for actual transactions, otherwise admits immediately. This is equivalent to AFTER_COMMIT + fallbackExecution but preserves the producer's span even if the span ends before commit. Each boundary catches telemetry RuntimeExceptions and counts them with bounded fixed-text diagnostics. No network IO, enrichment queries or space-waiting occurs in the listener. Disabled configuration creates no listener, provider, worker, exporter or OTel context objects. +The commit/rollback contract is physical-transaction scoped, not savepoint aware. `PROPAGATION_NESTED` savepoint rollback is explicitly excluded: an event dispatched inside the rolled-back savepoint still exports if the outer physical transaction commits. No current included producer configures NESTED. Producers needing savepoint-level suppression must not dispatch within nested/savepoint work. `REQUIRES_NEW` commits independently and exports even when the suspended outer transaction rolls back, suppressing only the outer event. PostgreSQL boundary tests assert both database rows and exact exported records; this is an honest exclusion, not new savepoint support. + Occurrence is `Instant.now()` immediately before producer publication, not the independently clocked persisted publishedAt or commit time. Sources identify domain branch, not caller-controlled Web/CLI labels. Forced internal auto-publication is a system actor; privileged human auto-publication remains a human actor. Anonymous downloads have actor type user and no user.id (user means user-request origin, not proof of an identified human). Review and promotion use the actual approver, not owner/uploader. Promotion decisions use source skill/namespace; effective publication uses the derived target. ## Bounds and observability -Standard SDK batching is retained. A nonwaiting semaphore around processor admission conservatively bounds queued PLUS in-flight records to `max-queue-size`; SDK queue capacity is at least that large, so accepted records cannot hit its hidden overflow boundary. A measured exporter releases permits when the SDK result completes and counts exported records or failed batches. This supplies exact admission/full metrics without reflection or a custom network queue/retry engine. SDK MeterProvider is explicitly noop; exporter health uses the existing Micrometer registry without global registration. +Standard SDK batching is retained. A nonwaiting semaphore around processor admission conservatively bounds queued PLUS in-flight records to `max-queue-size`; SDK queue capacity is at least that large, so accepted records cannot hit its hidden overflow boundary. A measured exporter owns a first-completion-wins result: SDK completion or the existing `(timeout + retry-max-backoff) * retry-max-attempts + 1s` deadline releases permits and updates counters exactly once. This handles OTel 1.31 async response-body exceptions that abandon their result; deadline/close completion counts one failed batch and late SDK completion is ignored. One daemon scheduler removes canceled deadlines; close stops it and fails outstanding guards. It bounds local accounting, not remote delivery/cancellation. This supplies exact admission/full metrics without reflection or a custom network queue/retry engine. SDK MeterProvider is explicitly noop; exporter health uses the existing Micrometer registry without global registration. + +Shipped Logback configuration sets `io.opentelemetry.exporter` and `io.opentelemetry.sdk.logs` (and inherited child loggers) OFF. These JUL diagnostics are bridged by Boot and may otherwise disclose arbitrary receiver Status messages or transport exception text echoing headers; ERROR-only is insufficient. SkillHub's fixed warning (at most once/minute/pipeline) and Micrometer counters remain the operator signal. This also suppresses shared Trace exporter diagnostics, not its exports. Custom logging must retain these defaults; a deliberate case-sensitive `logging.level` override can temporarily enable unsafe details, preferably only with synthetic credentials and restricted logs, then restore OFF/remove child overrides. The operator guide documents that troubleshooting path. A single mapper allows only known event classes, fixed sources/actor types, scalar IDs, enums and bounded user ID. The SDK also limits 16 attributes and 256 code units. Three Resource strings are separately bounded. Configuration has hard count/duration/header/list bounds. Admitted pipeline memory is bounded by record count and fixed record shape, not a promised byte-accurate or zero-allocation budget. Before commit, transaction synchronizations retain one bounded mapped snapshot per dispatched event outside the SDK admission window; callers must not wrap unbounded bulk work in one transaction. This is not a process-wide heap cap. Saturation latency and receiver container limits are measured in tests; this is not a production throughput benchmark. diff --git a/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md b/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md index dc9aa3f7c..5fba11930 100644 --- a/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md +++ b/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md @@ -18,9 +18,16 @@ The application SHALL create a dedicated LoggerProvider only when `skillhub.obse The event catalog in `docs/business-event-logs.md` SHALL define all included events, producer paths, occurrence boundary, actor and fields. Publication SHALL cover auto-publication, PRIVATE confirmation, review approval and promotion approval. Download preparation SHALL NOT mean transfer, installation or execution success. #### Scenario: Commit and rollback -- **WHEN** a supported event is published within a transaction +- **WHEN** a supported event is published within a physical transaction (excluding savepoint-level rollback semantics) - **THEN** the adapter captures a scalar snapshot and trace context before handoff -- **AND** export admission occurs only after commit; rollback emits no record +- **AND** export admission occurs only after that physical transaction commits; its rollback emits no record + +#### Scenario: Savepoint exclusion and independent transaction +- **WHEN** `PROPAGATION_NESTED` work dispatches an event and rolls back its savepoint, but the outer physical transaction commits +- **THEN** savepoint-level rollback suppression is explicitly outside the contract; the event can still export despite the nested write rolling back +- **AND** producers requiring savepoint suppression SHALL NOT dispatch within nested/savepoint work +- **WHEN** `REQUIRES_NEW` work commits and the suspended outer transaction later rolls back +- **THEN** the independently committed event is admitted and the outer transaction's event is suppressed #### Scenario: Nontransactional download - **WHEN** an authorized PUBLISHED download successfully builds its result without an outer transaction @@ -41,6 +48,12 @@ The adapter SHALL use asynchronous SDK batching and standard bounded exporter re - **THEN** the same `log.record.uid` survives retries - **AND** receivers may deduplicate, without an exactly-once promise +#### Scenario: Abandoned export result +- **WHEN** an SDK export result never completes, including a truncated non-2xx response-body callback failure +- **THEN** the export-budget deadline SHALL fail the owned completion guard, release admission permits and count one failed batch +- **AND** result completion, deadline and close SHALL compete for exactly one cleanup; late results SHALL NOT release again or change counters +- **AND** later events SHALL be admitted and exported when the receiver recovers + ### Requirement: Data export SHALL use a single explicit allowlist A single mapper SHALL export only fixed documented scalar fields, including IDs of private resources. Namespace IDs and event names SHALL be operator-filterable. Human actors SHALL use only local opaque `user.id`; anonymous/unknown and system actors SHALL omit it. No entity serialization, enrichment queries, resource autodetection, content, credentials, URLs, IP/UA, request IDs or raw headers is permitted. @@ -54,6 +67,14 @@ A single mapper SHALL export only fixed documented scalar fields, including IDs - **THEN** only IDs, visibility and documented operational fields are exported - **AND** names, slugs and content remain excluded +#### Scenario: Context and diagnostic credential boundaries +- **WHEN** MDC and OTel Baggage contain distinct sensitive sentinels +- **THEN** neither keys nor values SHALL appear in exported attributes, body or resource +- **WHEN** receiver Status messages or transport exceptions echo transport credentials +- **THEN** shipped logging SHALL suppress raw `io.opentelemetry.exporter` and `io.opentelemetry.sdk.logs` diagnostics, including child loggers, by default +- **AND** fixed-text rate-bounded SkillHub diagnostics and counters SHALL remain available +- **AND** operator documentation SHALL warn that explicitly raising SDK logging opts into possible credential disclosure + ### Requirement: Deployment and delivery limitations SHALL be explicit User documentation SHALL include every configuration key/default/bound, direct and Collector deployment, Secret injection, loss/duplicate/shutdown semantics, SDK compatibility limits, troubleshooting, a reproducible real receiver/query example and an acceptance matrix separating executed from unverified scenarios. diff --git a/openspec/changes/add-otlp-business-event-logs/tasks.md b/openspec/changes/add-otlp-business-event-logs/tasks.md index c38dc9706..74502841f 100644 --- a/openspec/changes/add-otlp-business-event-logs/tasks.md +++ b/openspec/changes/add-otlp-business-event-logs/tasks.md @@ -24,3 +24,13 @@ - [x] Verify cleanup of every owned temporary container/network/volume and the authorized baseline worktree; leave changes uncommitted. See acceptance.md for final executed results and explicit unverified deployment scenarios; a task checkbox is not itself acceptance evidence. + +## Repair 1 + +- [x] Reproduce truncated HTTP error-body permit leak before production changes; add bounded first-completion-wins export accounting and late success/failure regression cases. +- [x] Reproduce synthetic credential disclosure in receiver Status and transport-error diagnostics; disable raw exporter/Logs SDK loggers by default and verify both shipped log formats retain safe operator signals. +- [x] Explicitly exclude NESTED savepoint rollback from suppression; verify PostgreSQL NESTED rollback/outer commit and REQUIRES_NEW commit/outer rollback plus documentation consistency. +- [x] Make MDC/Baggage exclusion non-vacuous with distinct seeded sentinels, dispatch preconditions and exported attributes/body/resource checks. +- [x] Retain RED/GREEN evidence and rerun full focused command: 299 tests green; deployment contracts: 4 Python cases plus Helm/release shell suites green. + +Repair 1 results and intermediate failures are recorded in acceptance.md and the appended worker report; earlier broad-suite/site-build limitations remain explicit. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsPipeline.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsPipeline.java index 5851cb128..869debde6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsPipeline.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsPipeline.java @@ -14,6 +14,9 @@ import io.opentelemetry.sdk.logs.export.LogRecordExporter; import io.opentelemetry.sdk.resources.Resource; import java.time.Duration; import java.util.Collection; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -30,31 +33,49 @@ public final class BusinessLogsPipeline implements AutoCloseable { private final BusinessLogsMetrics metrics; private final Duration shutdownTimeout; private final AtomicBoolean accepting = new AtomicBoolean(true); + private final ScheduledThreadPoolExecutor exportDeadlines; + private final Set pendingExports = ConcurrentHashMap.newKeySet(); BusinessLogsPipeline(BusinessLogsProperties properties, Resource resource, LogRecordExporter exporter, BusinessLogsMetrics metrics) { this.exporter = exporter; this.metrics = metrics; this.shutdownTimeout = properties.getShutdownTimeout(); + exportDeadlines = new ScheduledThreadPoolExecutor(1, task -> { + Thread thread = new Thread(task, "skillhub-business-logs-deadlines"); + thread.setDaemon(true); + return thread; + }); + exportDeadlines.setRemoveOnCancelPolicy(true); Semaphore capacity = new Semaphore(properties.getMaxQueueSize()); LogRecordExporter measured = new LogRecordExporter() { @Override public CompletableResultCode export(Collection records) { int count = records.size(); + // Own the completion boundary: an SDK async callback can throw without completing + // its result (e.g. 1.31 reading a truncated error body). The first outcome wins; + // late completion cannot release permits or update counters a second time. + CompletableResultCode bounded = new CompletableResultCode(); + pendingExports.add(bounded); + bounded.whenComplete(() -> { + pendingExports.remove(bounded); + capacity.release(count); + if (bounded.isSuccess()) { metrics.exported.increment(count); } + else { metrics.exportFailures.increment(); metrics.warn(); } + }); try { + var deadline = exportDeadlines.schedule(bounded::fail, + properties.exportBudget().toNanos(), TimeUnit.NANOSECONDS); + bounded.whenComplete(() -> deadline.cancel(false)); CompletableResultCode result = exporter.export(records); result.whenComplete(() -> { - capacity.release(count); - if (result.isSuccess()) { metrics.exported.increment(count); } - else { metrics.exportFailures.increment(); metrics.warn(); } + if (result.isSuccess()) { bounded.succeed(); } + else { bounded.fail(); } }); - return result; } catch (RuntimeException ex) { - capacity.release(count); - metrics.exportFailures.increment(); - metrics.warn(); - return CompletableResultCode.ofFailure(); + bounded.fail(); } + return bounded; } @Override public CompletableResultCode flush() { return exporter.flush(); } @Override public CompletableResultCode shutdown() { return exporter.shutdown(); } @@ -111,6 +132,11 @@ public final class BusinessLogsPipeline implements AutoCloseable { metrics.error(); // Also cancel network work if shutdown itself unexpectedly fails. try { exporter.shutdown(); } catch (RuntimeException shutdownException) { metrics.error(); } + } finally { + // No deadline thread/tasks survive this lifecycle. A concurrent late export either + // appears here or is rejected by the stopped scheduler and fails its own guard. + exportDeadlines.shutdownNow(); + pendingExports.forEach(CompletableResultCode::fail); } } } diff --git a/server/skillhub-app/src/main/resources/logback-spring.xml b/server/skillhub-app/src/main/resources/logback-spring.xml index c9372168c..803ac9180 100644 --- a/server/skillhub-app/src/main/resources/logback-spring.xml +++ b/server/skillhub-app/src/main/resources/logback-spring.xml @@ -45,6 +45,12 @@ + + + + diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java new file mode 100644 index 000000000..6027cee9c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java @@ -0,0 +1,117 @@ +package com.iflytek.skillhub.observability.business; + +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.logging.LoggingInitializationContext; +import org.springframework.boot.logging.LoggingSystem; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.env.MapPropertySource; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Load the shipped logging config, including JUL-to-SLF4J routing used by Boot. */ +@ExtendWith(OutputCaptureExtension.class) +class BusinessLogsDiagnosticsTest { + private static final String CREDENTIAL = "SYNTHETIC-1009-CREDENTIAL"; + private LoggingSystem logging; + + private void configureShippedLogging(String format) { + logging = LoggingSystem.get(getClass().getClassLoader()); + logging.beforeInitialize(); + var environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("diagnostic-test", + java.util.Map.of("skillhub.observability.log-format", format))); + logging.initialize(new LoggingInitializationContext(environment), "classpath:logback-spring.xml", null); + for (String name : java.util.List.of("io.opentelemetry.exporter.internal.http.HttpExporter", + "io.opentelemetry.exporter.sender.okhttp.internal.OkHttpHttpSender", + "io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor")) { + org.slf4j.LoggerFactory.getLogger(name); + assertThat(logging.getLoggerConfiguration(name).getEffectiveLevel()) + .isEqualTo(org.springframework.boot.logging.LogLevel.OFF); + } + } + + @AfterEach + void cleanupLogging() { + if (logging != null) { + logging.cleanUp(); + logging.initialize(new LoggingInitializationContext(new StandardEnvironment()), "classpath:logback-test.xml", null); + logging.cleanUp(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"text", "json"}) + void receiverStatusEchoingAuthorizationCannotLeakToAnyConsoleLog(String format, CapturedOutput output) throws Exception { + configureShippedLogging(format); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/logs", exchange -> { + exchange.getRequestBody().readAllBytes(); + String message = "auth failed for " + exchange.getRequestHeaders().getFirst("Authorization"); + byte[] text = message.getBytes(StandardCharsets.UTF_8); + // google.rpc.Status { code: 16, message: } + byte[] body = new byte[text.length + 4]; + body[0] = 0x08; body[1] = 0x10; body[2] = 0x12; body[3] = (byte) text.length; + System.arraycopy(text, 0, body, 4, text.length); + exchange.getResponseHeaders().add("Content-Type", "application/x-protobuf"); + exchange.sendResponseHeaders(401, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + exportFailure("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/logs"); + org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll()).contains("Business Logs telemetry failed")); + assertThat(output.getAll()).contains("Business Logs telemetry failed") + .doesNotContain(CREDENTIAL, "auth failed for", "Failed to export logs"); + } finally { server.stop(0); } + } + + @ParameterizedTest + @ValueSource(strings = {"text", "json"}) + void transportExceptionEchoingSensitiveTextCannotLeakToAnyConsoleLog(String format, CapturedOutput output) throws Exception { + configureShippedLogging(format); + // OkHttp reports an invalid status line verbatim in ProtocolException (transport onError). + try (var receiver = new BusinessLogsTestReceiver( + "HTTP/1.1 " + CREDENTIAL + "\r\nConnection: close\r\n\r\n")) { + exportFailure(receiver.endpoint()); + assertThat(receiver.requests.get()).isEqualTo(1); + org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll()).contains("Business Logs telemetry failed")); + assertThat(output.getAll()).contains("Business Logs telemetry failed") + .doesNotContain(CREDENTIAL, "Unexpected status line", "Failed to export logs"); + } + } + + private static void exportFailure(String endpoint) { + new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class) + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withPropertyValues("skillhub.observability.business-logs.enabled=true", + "skillhub.observability.business-logs.endpoint=" + endpoint, + "skillhub.observability.business-logs.headers=Authorization=Bearer%20" + CREDENTIAL, + "skillhub.observability.business-logs.retry-max-attempts=1", + "skillhub.observability.business-logs.timeout=500ms") + .run(context -> { + assertThat(context).hasNotFailed(); + var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now()); + context.publishEvent(new SkillPublishedEvent(1L, 2L, "reviewer", snapshot)); + context.getBean(BusinessLogsPipeline.class).forceFlush().join(5, TimeUnit.SECONDS); + var metrics = context.getBean(BusinessLogsMetrics.class); + assertThat(metrics.exportFailures.count()).isEqualTo(1); + assertThat(metrics.exported.count()).isZero(); + }); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java index 73749ae00..e7b31b3cc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java @@ -17,10 +17,49 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.assertThat; class BusinessLogsHttpTest { + @Test + void truncatedErrorBodyCannotPermanentlyExhaustAdmission() throws Exception { + try (var receiver = new BusinessLogsTestReceiver( + "HTTP/1.1 400 Bad Request\r\nContent-Length: 100\r\nConnection: close\r\n\r\nx")) { + new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class) + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withPropertyValues("skillhub.observability.business-logs.enabled=true", + "skillhub.observability.business-logs.endpoint=" + receiver.endpoint(), + "skillhub.observability.business-logs.max-queue-size=1", + "skillhub.observability.business-logs.max-export-batch-size=1", + "skillhub.observability.business-logs.timeout=100ms", + "skillhub.observability.business-logs.retry-max-attempts=1", + "skillhub.observability.business-logs.retry-max-backoff=1ms", + "skillhub.observability.business-logs.retry-initial-backoff=1ms") + .run(context -> { + assertThat(context).hasNotFailed(); + var pipeline = context.getBean(BusinessLogsPipeline.class); + var metrics = context.getBean(BusinessLogsMetrics.class); + var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now()); + var event = new SkillPublishedEvent(1L, 2L, "reviewer", snapshot); + context.publishEvent(event); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + // Wait beyond the bounded export budget, not just the HTTP timeout. + org.awaitility.Awaitility.await().atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(metrics.exportFailures.count()).isEqualTo(1)); + for (int i = 0; i < 2; i++) { + context.publishEvent(event); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + } + assertThat(receiver.requests.get()).isEqualTo(3); + assertThat(metrics.enqueued.count()).isEqualTo(3); + assertThat(metrics.exported.count()).isEqualTo(2); + assertThat(metrics.queueFull.count()).isZero(); + assertThat(metrics.exportFailures.count()).isEqualTo(1); + }); + } + } + @Test void boundedRetryReusesIdenticalUidAndPayloadAndAuthenticationIsTransportOnly() throws Exception { var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsPipelineTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsPipelineTest.java new file mode 100644 index 000000000..f0f94df33 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsPipelineTest.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.observability.business; + +import com.iflytek.skillhub.domain.event.BusinessEventSnapshot; +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.resources.Resource; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class BusinessLogsPipelineTest { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void lateCompletionAfterDeadlineCannotReleaseTwiceOrChangeOutcome(boolean lateSuccess) throws Exception { + var properties = new BusinessLogsProperties(); + properties.setMaxQueueSize(1); + properties.setMaxExportBatchSize(1); + properties.setTimeout(Duration.ofMillis(100)); + properties.setRetryMaxAttempts(1); + properties.setRetryMaxBackoff(Duration.ofMillis(1)); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var exports = new LinkedBlockingQueue(); + var exporter = new LogRecordExporter() { + @Override public CompletableResultCode export(Collection records) { + var result = new CompletableResultCode(); + exports.add(result); + return result; + } + @Override public CompletableResultCode flush() { return CompletableResultCode.ofSuccess(); } + @Override public CompletableResultCode shutdown() { return CompletableResultCode.ofSuccess(); } + }; + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) { + var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now()); + var record = new BusinessEventMapper(properties).map(new SkillPublishedEvent(1L, 2L, "reviewer", snapshot)).orElseThrow(); + pipeline.emit(record, Context.root()); + var abandoned = exports.poll(5, TimeUnit.SECONDS); + assertThat(abandoned).isNotNull(); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(metrics.exportFailures.count()).isEqualTo(1)); + pipeline.emit(record, Context.root()); + var recovering = exports.poll(5, TimeUnit.SECONDS); + assertThat(recovering).isNotNull(); + if (lateSuccess) { abandoned.succeed(); } else { abandoned.fail(); } + // The recovered permit is now occupied again. Late completion must not add capacity. + pipeline.emit(record, Context.root()); + assertThat(metrics.enqueued.count()).isEqualTo(2); + assertThat(metrics.queueFull.count()).isEqualTo(1); + assertThat(metrics.exported.count()).isZero(); + assertThat(metrics.exportFailures.count()).isEqualTo(1); + recovering.succeed(); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(metrics.exported.count()).isEqualTo(1); + assertThat(metrics.exportFailures.count()).isEqualTo(1); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTestReceiver.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTestReceiver.java new file mode 100644 index 000000000..d78eea22e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTestReceiver.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.observability.business; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +/** Raw local HTTP peer for responses that HttpServer deliberately cannot produce. */ +final class BusinessLogsTestReceiver implements AutoCloseable { + private final ServerSocket server; + private final Thread worker; + private final String firstResponse; + final AtomicInteger requests = new AtomicInteger(); + private volatile Throwable failure; + + BusinessLogsTestReceiver(String firstResponse) throws IOException { + this.firstResponse = firstResponse; + server = new ServerSocket(0, 10, InetAddress.getByName("127.0.0.1")); + worker = new Thread(this::receive, "skillhub-1009-http-test"); + worker.setDaemon(true); + worker.start(); + } + + String endpoint() { return "http://127.0.0.1:" + server.getLocalPort() + "/v1/logs"; } + + private void receive() { + try { + while (!server.isClosed()) { + try (Socket socket = server.accept()) { + socket.setSoTimeout(5000); + var input = new BufferedInputStream(socket.getInputStream()); + int length = 0; + String line; + while (!(line = line(input)).isEmpty()) { + if (line.toLowerCase(java.util.Locale.ROOT).startsWith("content-length:")) { + length = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim()); + } + } + if (input.readNBytes(length).length != length) { throw new IOException("Incomplete test request"); } + int count = requests.incrementAndGet(); + String response = count == 1 ? firstResponse + : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + socket.getOutputStream().write(response.getBytes(StandardCharsets.ISO_8859_1)); + socket.getOutputStream().flush(); + } + } + } catch (IOException ex) { + if (!server.isClosed()) { failure = ex; } + } catch (RuntimeException ex) { failure = ex; } + } + + private static String line(BufferedInputStream input) throws IOException { + var bytes = new ByteArrayOutputStream(); + int value; + while ((value = input.read()) != -1) { + if (value == '\n') { return bytes.toString(StandardCharsets.ISO_8859_1).trim(); } + bytes.write(value); + } + throw new IOException("Incomplete test headers"); + } + + @Override + public void close() throws Exception { + server.close(); + worker.join(6000); + if (worker.isAlive()) { throw new AssertionError("HTTP test peer did not stop"); } + if (failure != null) { throw new AssertionError("HTTP test peer failed", failure); } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java index bd07d0426..c8f800de7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillVisibility; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.baggage.Baggage; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.TraceFlags; @@ -29,11 +30,13 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; +import org.slf4j.MDC; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.TransactionDefinition; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -102,6 +105,79 @@ class BusinessLogsTransactionTest { } } + @Test + void nestedSavepointRollbackIsExplicitlyOutsideSuppressionContract() { + var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("create table nested_analytics_probe (id int primary key)"); + var manager = new DataSourceTransactionManager(dataSource); + var outer = new TransactionTemplate(manager); + var nested = new TransactionTemplate(manager); + nested.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED); + var exporter = new CapturingExporter(); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var properties = new BusinessLogsProperties(); + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) { + var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics); + outer.executeWithoutResult(status -> { + nested.executeWithoutResult(savepoint -> { + assertThat(savepoint.hasSavepoint()).isTrue(); + jdbc.update("insert into nested_analytics_probe values (1)"); + listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot)); + savepoint.setRollbackOnly(); + }); + assertThat(jdbc.queryForObject("select count(*) from nested_analytics_probe", Integer.class)).isZero(); + assertThat(metrics.enqueued.count()).isZero(); + }); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("select count(*) from nested_analytics_probe", Integer.class)).isZero(); + // Synchronization follows the physical transaction, not the rolled-back savepoint. + assertThat(exporter.records).hasSize(1); + } + } + + @Test + void requiresNewCommitExportsEvenWhenOuterTransactionRollsBack() { + var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("create table requires_new_analytics_probe (id int primary key)"); + var manager = new DataSourceTransactionManager(dataSource); + var outer = new TransactionTemplate(manager); + var independent = new TransactionTemplate(manager); + independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + var exporter = new CapturingExporter(); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + var properties = new BusinessLogsProperties(); + try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) { + var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics); + outer.executeWithoutResult(status -> { + jdbc.update("insert into requires_new_analytics_probe values (1)"); + listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot)); + independent.executeWithoutResult(inner -> { + jdbc.update("insert into requires_new_analytics_probe values (2)"); + listener.onEvent(new SkillPublishedEvent(3L, 4L, "approver", snapshot)); + }); + assertThat(metrics.enqueued.count()).isEqualTo(1); + status.setRollbackOnly(); + }); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(jdbc.queryForList("select id from requires_new_analytics_probe", Integer.class)).containsExactly(2); + assertThat(exporter.records).hasSize(1); + assertThat(exporter.records.getFirst().getAttributes().get(AttributeKey.longKey("skillhub.skill.id"))).isEqualTo(3L); + } + } + + @Test + void transactionDocumentationStatesSavepointExclusionAndIndependentCommit() throws Exception { + for (String path : List.of("docs/business-event-logs.md", + "openspec/changes/add-otlp-business-event-logs/design.md", + "openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md", + "openspec/changes/add-otlp-business-event-logs/acceptance.md")) { + assertThat(java.nio.file.Files.readString(java.nio.file.Path.of("../..", path))) + .as(path).contains("PROPAGATION_NESTED", "REQUIRES_NEW"); + } + } + @Test void unsampledSpanCapturedBeforeCommitAndNoMdcOrBaggageExported() { var dataSource = new DriverManagerDataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); @@ -113,7 +189,11 @@ class BusinessLogsTransactionTest { try (var pipeline = new BusinessLogsPipeline(properties, Resource.empty(), exporter, metrics)) { var listener = new BusinessEventListener(new BusinessEventMapper(properties), pipeline, metrics); transaction.executeWithoutResult(status -> { - try (var ignored = Span.wrap(spanContext).makeCurrent()) { + try (var mdc = MDC.putCloseable("sensitive.mdc", "SYNTHETIC-1009-MDC-SECRET"); + var baggage = Baggage.builder().put("sensitive.baggage", "SYNTHETIC-1009-BAGGAGE-SECRET").build().makeCurrent(); + var ignored = Span.wrap(spanContext).makeCurrent()) { + assertThat(MDC.get("sensitive.mdc")).isEqualTo("SYNTHETIC-1009-MDC-SECRET"); + assertThat(Baggage.current().getEntryValue("sensitive.baggage")).isEqualTo("SYNTHETIC-1009-BAGGAGE-SECRET"); listener.onEvent(new SkillPublishedEvent(1L, 2L, "approver", snapshot)); } assertThat(Span.current().getSpanContext().isValid()).isFalse(); @@ -122,6 +202,12 @@ class BusinessLogsTransactionTest { assertThat(exporter.records).hasSize(1); assertThat(exporter.records.getFirst().getSpanContext()).isEqualTo(spanContext); assertThat(exporter.records.getFirst().getSpanContext().isSampled()).isFalse(); + var exported = exporter.records.getFirst(); + for (String value : List.of(exported.getAttributes().toString(), String.valueOf(exported.getBody().asString()), + exported.getResource().getAttributes().toString())) { + assertThat(value).doesNotContain("sensitive.mdc", "sensitive.baggage", + "SYNTHETIC-1009-MDC-SECRET", "SYNTHETIC-1009-BAGGAGE-SECRET"); + } } } From bd30ac1ff51ba4c363193ffb3e9be0b19a6353d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= <258399167+xiaoyaner0201@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:11:33 +0800 Subject: [PATCH 06/21] fix(observability): contain exporter callback failures and assert exported identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com> --- docs/business-event-logs.md | 6 +- .../acceptance.md | 21 ++++ .../add-otlp-business-event-logs/design.md | 10 ++ .../specs/business-event-logs/spec.md | 13 +++ .../add-otlp-business-event-logs/tasks.md | 11 +++ .../business/BusinessLogsConfiguration.java | 8 +- .../BusinessLogsExporterBoundary.java | 57 +++++++++++ .../business/BusinessLogsDiagnosticsTest.java | 55 +++++++++++ .../BusinessLogsExporterBoundaryTest.java | 49 +++++++++ .../BusinessLogsProducerPostgresTest.java | 99 ++++++++++++++++++- 10 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsExporterBoundary.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsExporterBoundaryTest.java diff --git a/docs/business-event-logs.md b/docs/business-event-logs.md index 95cf43c5f..01d8cbfdd 100644 --- a/docs/business-event-logs.md +++ b/docs/business-event-logs.md @@ -146,12 +146,16 @@ Micrometer names on the existing protected Actuator metrics surface (Prometheus | `skillhub.business.logs.enqueued` | Records admitted / 已接纳记录 | | `skillhub.business.logs.dropped{reason=queue_full}` | Records refused because outstanding budget is full / 满队列丢弃 | | `skillhub.business.logs.dropped{reason=shutdown}` | New records refused during closing / 退出时拒绝新记录 | -| `skillhub.business.logs.errors` | Adapter mapping/admission/lifecycle exceptions, swallowed / 适配器异常(不传播) | +| `skillhub.business.logs.errors` | Adapter mapping/admission/lifecycle exceptions and contained exporter dispatcher exceptions, swallowed / 适配器及已隔离的导出回调异常(不传播) | | `skillhub.business.logs.export.failures` | Batches whose SDK export fails/throws, exceeds the export budget, or is still outstanding at close / 最终失败、超时或关闭时仍未结束的批次 | | `skillhub.business.logs.exported` | Records in SDK-successful batches; may include receiver partial rejections / SDK 成功记录,含部分拒收盲区 | No per-user/skill/namespace metric tags. Adapter diagnostics are fixed text, at most once per minute per pipeline, without exception messages, headers or event payloads: `Business Logs telemetry failed; inspect skillhub.business.logs metrics and receiver health`. Shipped `logback-spring.xml` sets `io.opentelemetry.exporter` and `io.opentelemetry.sdk.logs` to OFF by default, including their child loggers routed from JUL by Spring Boot. Raw SDK diagnostics can echo receiver error bodies or transport exception text containing credentials; throttling or ERROR level alone is NOT safe. This also silences shared OTel exporter diagnostics for Trace; Trace export behavior is unchanged. Custom logging configurations must retain these OFF rules. / 指标无高基数标签;适配器每分钟最多一次固定文字。默认关闭 OTel exporter 与 sdk.logs 原始诊断(包括子记录器及 JUL 桥接),其响应或异常文本可能回显凭证,ERROR 级别也不安全;同时静默共享 Trace exporter 诊断但不改变导出。自定义日志配置需保留这些规则。 +Logger OFF alone does not contain OTel 1.31's uncaught asynchronous callback failures (for example, an HTTP 401 with a malformed chunked body). The production business exporter is constructed in a private ThreadGroup: only its `okhttp-dispatch-*` threads' uncaught exceptions are discarded without rendering or forwarding the throwable. This increments `errors` and retains the same fixed warning; the existing completion deadline separately counts one failed batch and recovers permits. The JVM default handler is never changed. Other threads, including same-named threads outside that group and shared OkHttp housekeeping threads, delegate to their normal parent/default handler unchanged. Pipeline shutdown cancels its dispatcher executor; containment stays attached until its last callback exits, then no owned dispatcher remains. An inert group can outlive the pipeline for shared OkHttp housekeeping, but delegates those exceptions and only weakly references metrics. / 仅关闭 logger 不足以阻止 1.31 异步回调异常直写 stderr。业务 exporter 在私有线程组内构建,只隔离该组导出 dispatcher 的未捕获异常,计 errors 并保留固定告警;预算到期另计一批失败并恢复许可。不改 JVM 默认 handler,其它线程仍按原处理链执行。关闭时取消导出线程,隔离保留至回调退出;共享 housekeeping 可保留空闲边界组,但不受抑制且不强引用指标。 + +This safe-diagnostics boundary covers this dedicated business Logs pipeline with the shipped logger OFF defaults and the verified managed 1.31 sender. It is not process-wide exception redaction: unrelated Trace/agent exporters, application failures, custom logging/SDK overrides, agents intercepting exceptions, heap dumps and receiver-side logs are outside it. SDK upgrades must reverify dispatcher ownership/naming and the captured stdout/stderr tests; no BOM upgrade is included here. / 安全诊断范围限于默认日志规则和已验证的 1.31 业务管道,不是整个 JVM 的异常脱敏;其它 exporter、应用异常、配置覆盖、Agent、堆转储和接收端日志不在范围内。升级 SDK 必须重验线程归属与输出回归。 + Troubleshoot using the protected Actuator counters above, this fixed warning, receiver-side status/health, and independent DNS/network/TLS/auth checks with secret-safe tools. If raw SDK diagnostics are indispensable, first remove production credentials or reproduce with synthetic credentials in an isolated environment, restrict log access/retention, and knowingly accept disclosure risk. Temporarily set the exact Spring property `logging.level.io.opentelemetry.exporter.internal.http.HttpExporter=DEBUG` (e.g. a deployment-owned config file or `SPRING_APPLICATION_JSON`); `logging.level.io.opentelemetry.sdk.logs=DEBUG` opts into Logs processor diagnostics separately. Logger names are case-sensitive; do not rely on relaxed uppercase env names for the class logger. Restore both package defaults to OFF and remove any child-logger override after diagnosis, then restart; do not share unredacted SDK output. Raising root logging alone does not override explicit package OFF. / 排障优先使用受保护指标、固定告警及接收端健康,不输出 Secret。确需 SDK 原始细节时,在隔离环境用假凭证复现,限制访问与保留并明确接受泄露风险;临时提高精确日志属性(类名区分大小写),结束后恢复 OFF、删除子记录器覆盖并重启,勿分享未脱敏输出。 | Symptom / 现象 | Check / 排查 | diff --git a/openspec/changes/add-otlp-business-event-logs/acceptance.md b/openspec/changes/add-otlp-business-event-logs/acceptance.md index 798a62085..2ff94f91a 100644 --- a/openspec/changes/add-otlp-business-event-logs/acceptance.md +++ b/openspec/changes/add-otlp-business-event-logs/acceptance.md @@ -65,6 +65,27 @@ Repair base: `894e5860ce19059a27dab8cf94642aba56d33a29`; changes remain uncommit The first full rerun (`final-focused.log`) executed 299 tests but had 4 diagnostic-test setup errors: querying an uncreated Logback child returned null. The fixture now creates the child before checking its inherited OFF level; the subsequent complete rerun above is green. `green-repair-focused.log` is an earlier 15-test pass before extending diagnostics to both log formats; it is not the final count. No broad-backend/base comparison or docs-site build was rerun in Repair 1; the limitations below are unchanged. / 首次全定向检查中诊断测试的子 logger 尚未创建,4 项 setup 报错;修正测试 fixture 后完整重跑通过。不将中间失败或早期 15 项绿色记录冒充最终结果;本轮未重跑全量后端/基线对比或文档站构建。 +## Repair 2 — second independent review RETURN + +Repair base and unchanged HEAD: `74e2bc1457bcb1046036ed1aee185b92e9751ab7`, branch `feat/otlp-business-events-1009`; changes are uncommitted. This section supersedes Repair 1's incomplete diagnostic-containment claim, not its historical results. Evidence filenames below are relative to `evidence/repair2/` in the worker report. / 第二轮修复以此提交为基线且不提交;本节替代上一轮不完整的诊断隔离声明,历史运行结果仍保留。 + +Full Repair 1 focused command/environment rerun: **307 tests, 0 failures, 0 errors, 0 skips** (193 domain + 114 app; 81 business-Logs cases). Counts include nested JUnit classes and are parsed from this run, not stale Surefire output. Business cases: mapper 6, configuration 10, HTTP 3, transaction 8, pipeline guard 2, diagnostics 6, exporter boundary 2, producer/JPA 43, Collector 1. / 完整定向命令重跑 307 项全部通过,包含嵌套测试类,非旧报告计数。 + +| Finding / boundary | Executed assertion / implementation | RED → GREEN evidence | +| --- | --- | --- | +| Uncaught credential-bearing callback | Exact local raw HTTP 401 + Transfer-Encoding chunked + invalid first chunk `1 Bearer `. BEFORE fix both shipped text/JSON modes print raw IllegalStateException/ProtocolException to captured stderr, despite successful deadline recovery. AFTER fix neither stdout nor stderr (including real Logback console output) contains the placeholder or uncaught-exception text; fixed warning remains. Requests=2, enqueued=2, failures=1, callback errors=1, exported=1, queue-full=0; later event exports without restart | RED `red-chunked-diagnostics-runtime.log`: 2 tests / 2 assertion failures / 0 errors; GREEN `green-diagnostics-boundary-final.log` and `final-focused-green.log`, 6 diagnostic cases | +| Narrow ownership/lifecycle | Private ThreadGroup contains only its `okhttp-dispatch-*` uncaught exceptions; actual production dispatcher group asserted, dispatcher threads terminate after context close. JVM default handler identity unchanged before/during/after. Same-named unrelated thread outside the group and non-dispatcher inside it both reach the previous default handler with original throwable; initialization failure is fixed text with no cause, interrupted caller's flag restored | PASS `BusinessLogsExporterBoundaryTest` (2 cases), plus text/JSON lifecycle assertions in diagnostics | +| Actual managed API | Public 1.31 Logs builder has no executor/transport/thread-factory setter. Bytecode confirms per-exporter OkHttp dispatcher factory captures constructor thread's group. No BOM/dependency upgrade, global handler replacement, SDK-private import or reflection | `otel-1.31-builder-api.txt`, `otel-1.31-dispatcher-bytecode.txt`, `otel-1.31-thread-factory-bytecode.txt` | +| Producer identity gap | Every committed matrix row asserts exact skill/version/namespace IDs (including absence where not defined), source, current visibility, status old/new and visibility old/new (including absence on other events), plus review/promotion IDs. Promotion uses persisted distinct target skill/version/namespace for publication and source for decision. All 26 commit/rollback and 13 denial rows remain green | PASS `green-producer-identities.log`, `final-focused-green.log`; no observed identity bug or invented identity RED | +| Promotion filter gap | Both initial AND update approvals run with source-only and target-only namespace allowlists through real Spring producers and the production mapper/listener/pipeline; retain only source decision or target publication respectively; assert exact surviving record identity and no precommit record | PASS 4 additional PostgreSQL cases; producer total 43 | +| Rollback claim scope | Narrowed code comment: all 13 rollback rows prove zero telemetry; independent persisted rollback assertions additionally cover PRIVATE confirmation and content review approval only, not every row's full mutation set. Denied-row state checks remain separate | Source/comment and assertions in `BusinessLogsProducerPostgresTest` | +| Deployment regression | No external setting or logging XML changed; exporter wiring changed, so Python deployment contracts and existing Helm/release shell suites rerun with synthetic fixtures | PASS `deployment-business-logs.log`: 4/4, no skips; `deployment-helm-contracts.log`, `deployment-release-contracts.log`: suite exit 0 | +| Real receiver regression | Pinned Collector receives/query confirms 27 records / 9 event names / 27 unique UIDs. Paused receiver: 1000 caller events in 8 ms, 998 drops; stopped receiver: caller 0 ms, failure counted. Blocked exporter: 12 ms loop, 999 drops, close 218 ms with 100 ms SDK wait budget plus local overhead | PASS `final-focused-green.log`, `collector-received.jsonl`, `collector-query.tsv`, `collector-query-summary.json`; observations, not SLOs | + +An initial RED fixture run (`red-chunked-diagnostics.log`) failed 2 startup assertions because retry-initial-backoff exceeded the test's lowered maximum; corrected before the runtime RED above. The first boundary GREEN attempt (`green-diagnostics-boundary.log`) had 2 fixture failures: enumerating all private groups included shared OkHttp housekeeping retained from another context, rather than just live dispatcher groups. The final fixture filters dispatcher threads; all 8 diagnostic/boundary cases pass in `green-diagnostics-boundary-final.log`, then the full run passes. Neither intermediate failure is presented as evidence of a passing run. / 初次 RED 为测试退避参数不一致;修正后实际复现泄露。首次 GREEN 因统计了共享 housekeeping 的线程组而失败,改为检查实际 dispatcher 后 8 项通过,再完整重跑通过。 + +Scope: containment is private to this verified managed business sender, with shipped SDK logger OFF defaults. Non-dispatcher housekeeping delegates unchanged; its inert group may remain alive but weakly references metrics. No global handler is installed/restored; containment lasts through owned callback exit. Unrelated exporters/application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs remain outside the guarantee. SDK upgrades require revalidation. Broad backend/base comparisons, packaging and docs-site build were not rerun in Repair 2; previous limitations below remain unchanged. `git diff --check` passes; owned containers/networks/volumes are cleaned up. / 隔离非全 JVM 脱敏,版本与默认日志范围及剩余部署责任详见设计和运维文档;本轮未重跑全量后端、打包或文档站构建,不冒称消除既有环境限制。 + ## Broad suite and environment limits Broad `make test-backend` runs are NOT green. Initial candidate: 2732 tests, 2 failures, 1 error, 30 skips. Final candidate broad run: 2762 tests, 3 failures, 11 errors, 30 skips. Frozen base: 2712 tests, 2 failures, 2 errors, 30 skips. Failures are in GitHub integration tests; exact failing methods are retained in the count JSONs. Four methods occur in both the final candidate's and base's broad failure sets (Go executor, credential privacy, two budget cases), but the full failure sets and error modes vary. Do NOT infer that every candidate failure was proven pre-existing. Subsequent identical five-class GitHub focused commands PASS all 37 tests on BOTH the candidate and frozen base. An independent SQL/host clock probe found the Docker database clock ahead of the host in every sample, consistent with timing-sensitive eligibility checks, but it is NOT asserted as the proven cause of every failure. No clock/global configuration was changed. The authorized detached base worktree was removed. / 全量检查不是绿色;基线与 candidate 有四个相同失败方法,但集合及错误形式不同,不冒称全部故障已证实为基线问题。随后相同五类 GitHub 定向测试在两边均 37 项通过。独立探测发现 Docker 数据库时钟领先宿主机,与时间筛选类故障相符,但未证明所有故障根因;未改系统时钟/全局配置。基线临时工作树已移除。 diff --git a/openspec/changes/add-otlp-business-event-logs/design.md b/openspec/changes/add-otlp-business-event-logs/design.md index b20789074..bf9344d7b 100644 --- a/openspec/changes/add-otlp-business-event-logs/design.md +++ b/openspec/changes/add-otlp-business-event-logs/design.md @@ -18,6 +18,16 @@ Standard SDK batching is retained. A nonwaiting semaphore around processor admis Shipped Logback configuration sets `io.opentelemetry.exporter` and `io.opentelemetry.sdk.logs` (and inherited child loggers) OFF. These JUL diagnostics are bridged by Boot and may otherwise disclose arbitrary receiver Status messages or transport exception text echoing headers; ERROR-only is insufficient. SkillHub's fixed warning (at most once/minute/pipeline) and Micrometer counters remain the operator signal. This also suppresses shared Trace exporter diagnostics, not its exports. Custom logging must retain these defaults; a deliberate case-sensitive `logging.level` override can temporarily enable unsafe details, preferably only with synthetic credentials and restricted logs, then restore OFF/remove child overrides. The operator guide documents that troubleshooting path. +### Scoped asynchronous exception boundary (Repair 2) + +The 1.31 `HttpExporter` can throw from OkHttp's callback while parsing a malformed non-2xx response body; logger OFF and the accounting deadline do not stop the JVM uncaught handler printing that throwable to stderr. `BusinessLogsExporterBoundary` constructs only this exporter on a short-lived initializer in a private ThreadGroup. Its uncaught handler suppresses only threads whose group is that exact object AND whose restored factory name starts `okhttp-dispatch-`. It never inspects/formats/forwards the throwable, increments the existing adapter error counter and emits only the fixed warning; the completion guard independently records one failed batch and releases permits at its deadline. Initializer failures pass through a FutureTask and become a fixed cause-free startup exception; an interrupted caller finishes ownership transfer and restores its interrupt flag. Failure while constructing the pipeline cancels the built exporter and throws fixed text without the raw cause. + +Why this rather than a global handler or new HTTP stack: `javap` against the actual managed 1.31 artifacts confirms the public `OtlpHttpLogRecordExporterBuilder` has NO transport/executor/thread-factory setter. `OkHttpUtil.newDispatcher()` constructs a per-exporter executor with `DaemonThreadFactory("okhttp-dispatch")`; its `Executors.defaultThreadFactory()` captures the constructing thread's group. This JDK ThreadGroup mechanism needs no SDK-private imports/reflection, dependency or BOM change and retains standard HTTP/retry behavior. The narrow version-specific assumption is dispatcher construction/group capture and restored naming, protected by the real receiver regression. Future SDK upgrades must rerun it and revisit the boundary, not assume compatibility. + +There is no process-wide installation or restoration: the default handler is never set by production code, no thread scans mutate handlers, and multiple pipelines own independent groups. The scope is attached at exporter construction, before any export, and retired with that exporter's dispatcher executor at shutdown. It MUST remain attached to late callbacks even after close returns rather than exposing a teardown race. Group members other than dispatchers delegate through `super.uncaughtException` unchanged. OkHttp may first initialize shared housekeeping in this group; such threads are NOT suppressed and can keep the otherwise inert group alive. Its metrics reference is weak so this cannot retain the closed pipeline/registry. Java 21 reclaims groups when unreachable; no deprecated `ThreadGroup.destroy()` or global cleanup is needed. Tests assert actual production dispatchers use the private group, vanish after close, and never change the previous default handler. Guard tests additionally throw on an unrelated same-named thread outside the group and a non-dispatcher inside it: both reach the previous default handler with the original throwable. + +Together with the shipped logger OFF rules and synchronous cause-free catches, this prevents raw business-exporter failure text in application stdout/stderr/logs, including malformed chunked 401 responses. It is deliberately NOT process-wide redaction: unrelated Trace/agent exporters, application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs are outside the guarantee. No previously installed handler is silenced for those threads. The existing shared Trace logger suppression remains the disclosed operational tradeoff; the new exception boundary adds no global handler side effect. + A single mapper allows only known event classes, fixed sources/actor types, scalar IDs, enums and bounded user ID. The SDK also limits 16 attributes and 256 code units. Three Resource strings are separately bounded. Configuration has hard count/duration/header/list bounds. Admitted pipeline memory is bounded by record count and fixed record shape, not a promised byte-accurate or zero-allocation budget. Before commit, transaction synchronizations retain one bounded mapped snapshot per dispatched event outside the SDK admission window; callers must not wrap unbounded bulk work in one transaction. This is not a process-wide heap cap. Saturation latency and receiver container limits are measured in tests; this is not a production throughput benchmark. ## SDK compatibility verified against 1.31.0 diff --git a/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md b/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md index 5fba11930..4725b1791 100644 --- a/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md +++ b/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md @@ -34,6 +34,11 @@ The event catalog in `docs/business-event-logs.md` SHALL define all included eve - **THEN** one download-prepared record is admitted - **AND** a rejected request, preview/review package, Suite plan or metadata poll emits no such record +#### Scenario: Promotion identities and namespace filters +- **WHEN** an initial or update promotion approval commits +- **THEN** publication SHALL identify the derived target skill/version/namespace, and the promotion decision SHALL identify the source skill/namespace with no invented version field +- **AND** a source-only namespace allowlist SHALL retain only the decision; a target-only allowlist SHALL retain only publication + ### Requirement: Export SHALL have bounded, nonblocking business-thread cost The adapter SHALL use asynchronous SDK batching and standard bounded exporter retry, never caller-runs network IO. Record count, batch size, attribute count/length, timeout, retry attempts/backoff and shutdown wait SHALL be bounded. Telemetry exceptions SHALL NOT propagate into business calls; failures SHALL be counted with bounded diagnostic logging. @@ -75,6 +80,14 @@ A single mapper SHALL export only fixed documented scalar fields, including IDs - **AND** fixed-text rate-bounded SkillHub diagnostics and counters SHALL remain available - **AND** operator documentation SHALL warn that explicitly raising SDK logging opts into possible credential disclosure +#### Scenario: Uncaught asynchronous exporter failure +- **WHEN** the dedicated managed business exporter throws from an asynchronous response callback, including HTTP 401 with a malformed chunk-size line containing synthetic credential text +- **THEN** its private dispatcher exception boundary SHALL prevent raw throwable text from reaching stdout, stderr or application logs with the shipped logging defaults +- **AND** the adapter error counter and fixed warning SHALL remain available; the completion deadline SHALL count one failed batch and recover capacity for a later successful event +- **AND** the boundary SHALL exist before export and remain on late callbacks through dispatcher shutdown, without changing the JVM default uncaught handler +- **AND** unrelated threads (even same-named threads outside the private group) and non-dispatcher group members SHALL delegate unchanged to their previous parent/default handling +- **AND** documentation SHALL scope this guarantee to the verified managed business sender and logging defaults, excluding unrelated exporters/application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs + ### Requirement: Deployment and delivery limitations SHALL be explicit User documentation SHALL include every configuration key/default/bound, direct and Collector deployment, Secret injection, loss/duplicate/shutdown semantics, SDK compatibility limits, troubleshooting, a reproducible real receiver/query example and an acceptance matrix separating executed from unverified scenarios. diff --git a/openspec/changes/add-otlp-business-event-logs/tasks.md b/openspec/changes/add-otlp-business-event-logs/tasks.md index 74502841f..82241d519 100644 --- a/openspec/changes/add-otlp-business-event-logs/tasks.md +++ b/openspec/changes/add-otlp-business-event-logs/tasks.md @@ -34,3 +34,14 @@ See acceptance.md for final executed results and explicit unverified deployment - [x] Retain RED/GREEN evidence and rerun full focused command: 299 tests green; deployment contracts: 4 Python cases plus Helm/release shell suites green. Repair 1 results and intermediate failures are recorded in acceptance.md and the appended worker report; earlier broad-suite/site-build limitations remain explicit. + +## Repair 2 + +- [x] Save runtime RED for malformed-chunked 401 response-body exceptions in both shipped logging formats, including captured stderr disclosure despite permit recovery. +- [x] Verify actual managed 1.31 builder API and dispatcher factory; contain only owned dispatcher uncaught failures with a private ThreadGroup, no global default-handler mutation or BOM upgrade. +- [x] Verify fixed diagnostics, errors/failure accounting, permit recovery, later successful export, lifecycle shutdown and unchanged previous handling of unrelated threads. +- [x] Assert complete exported identities/source/visibility/old-new fields for every committed producer row; add four real-producer initial/update source-only/target-only promotion filter cases; narrow persistent rollback claim. +- [x] Update operator guide/design/spec with the exact safe-diagnostics scope and residual risks; retain evidence of intermediate fixture failures. +- [x] Rerun full focused selector/environment: 307 tests green (81 business); real Collector query 27/9/27; deployment contracts 4/4 plus Helm/release shell suites green; diff and scoped cleanup checked. + +Repair 2 exact commands/counts/evidence and proposed fixup are appended to the worker report. No commit/push/comment was performed. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsConfiguration.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsConfiguration.java index 2e39c7f2c..c8f906b16 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsConfiguration.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsConfiguration.java @@ -38,7 +38,13 @@ public class BusinessLogsConfiguration { .put("service.version", BusinessEventMapper.bounded(environment.getProperty("skillhub.observability.service-version", "unknown"))) .put("deployment.environment", BusinessEventMapper.bounded(environment.getProperty("skillhub.observability.service-environment", "local"))) .build()); - return new BusinessLogsPipeline(properties, resource, builder.build(), metrics); + var exporter = BusinessLogsExporterBoundary.build(builder::build, metrics); + try { + return new BusinessLogsPipeline(properties, resource, exporter, metrics); + } catch (RuntimeException ex) { + try { exporter.shutdown(); } catch (RuntimeException shutdownException) { metrics.error(); } + throw new IllegalStateException("Business Logs pipeline initialization failed"); + } } @Bean diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsExporterBoundary.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsExporterBoundary.java new file mode 100644 index 000000000..8bf68b775 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsExporterBoundary.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.observability.business; + +import java.lang.ref.WeakReference; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.function.Supplier; + +/** + * Private dispatcher exception boundary for the managed OTel 1.31 OkHttp sender. + * Its DaemonThreadFactory captures the constructing thread's group. Construct only the exporter + * here, never application work or the batch processor. No JVM default handler is changed. + */ +final class BusinessLogsExporterBoundary extends ThreadGroup { + // OkHttp may initialize shared housekeeping threads in this group. They delegate normally and + // must not retain a closed pipeline's registry through this otherwise inert group. + private final WeakReference metrics; + + private BusinessLogsExporterBoundary(BusinessLogsMetrics metrics) { + super("skillhub-business-logs-http"); + this.metrics = new WeakReference<>(metrics); + } + + static T build(Supplier factory, BusinessLogsMetrics metrics) { + var result = new FutureTask(factory::get); + Thread initializer = new Thread(new BusinessLogsExporterBoundary(metrics), result, + "skillhub-business-logs-http-init"); + initializer.setDaemon(true); + initializer.start(); + boolean interrupted = false; + try { + // Construction is local (no receiver IO). Finish ownership transfer even on interruption, + // rather than abandoning a newly built exporter and its executor; restore the flag below. + while (true) { + try { return result.get(); } + catch (InterruptedException ex) { interrupted = true; } + catch (ExecutionException ex) { + // Never attach a potentially credential-bearing SDK construction exception. + throw new IllegalStateException("Business Logs exporter initialization failed"); + } + } + } finally { + if (interrupted) { Thread.currentThread().interrupt(); } + } + } + + @Override + public void uncaughtException(Thread thread, Throwable failure) { + if (thread.getThreadGroup() == this && thread.getName().startsWith("okhttp-dispatch-")) { + // Do not inspect, render or forward the throwable. The export deadline owns failed-batch + // accounting/permit release; this counts the callback error and emits only fixed text. + BusinessLogsMetrics current = metrics.get(); + if (current != null) { current.error(); } + } else { + super.uncaughtException(thread, failure); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java index 6027cee9c..4b88b7beb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java @@ -96,6 +96,61 @@ class BusinessLogsDiagnosticsTest { } } + @ParameterizedTest + @ValueSource(strings = {"text", "json"}) + void malformedChunkedUnauthorizedCannotPrintUncaughtExceptionAndLaterExportRecovers( + String format, CapturedOutput output) throws Exception { + configureShippedLogging(format); + try (var receiver = new BusinessLogsTestReceiver( + "HTTP/1.1 401 Unauthorized\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + + "1 Bearer " + CREDENTIAL + "\r\nx\r\n0\r\n\r\n")) { + var previousHandler = Thread.getDefaultUncaughtExceptionHandler(); + var ownedGroups = new java.util.HashSet(); + new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class) + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withPropertyValues("skillhub.observability.business-logs.enabled=true", + "skillhub.observability.business-logs.endpoint=" + receiver.endpoint(), + "skillhub.observability.business-logs.headers=Authorization=Bearer%20" + CREDENTIAL, + "skillhub.observability.business-logs.retry-max-attempts=1", + "skillhub.observability.business-logs.timeout=100ms", + "skillhub.observability.business-logs.retry-initial-backoff=100ms", + "skillhub.observability.business-logs.retry-max-backoff=100ms", + "skillhub.observability.business-logs.max-queue-size=1", + "skillhub.observability.business-logs.max-export-batch-size=1") + .run(context -> { + assertThat(context).hasNotFailed(); + var snapshot = new BusinessEventSnapshot(7L, SkillVisibility.PUBLIC, "reviewer", "user", "review", Instant.now()); + var event = new SkillPublishedEvent(1L, 2L, "reviewer", snapshot); + var pipeline = context.getBean(BusinessLogsPipeline.class); + var metrics = context.getBean(BusinessLogsMetrics.class); + context.publishEvent(event); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(metrics.exportFailures.count()).isEqualTo(1)); + context.publishEvent(event); + pipeline.forceFlush().join(5, TimeUnit.SECONDS); + org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(metrics.exported.count()).isEqualTo(1)); + assertThat(metrics.enqueued.count()).isEqualTo(2); + assertThat(metrics.queueFull.count()).isZero(); + assertThat(metrics.exportFailures.count()).isEqualTo(1); + assertThat(receiver.requests.get()).isEqualTo(2); + assertThat(metrics.errors.count()).isEqualTo(1); + Thread.getAllStackTraces().keySet().stream() + .filter(thread -> thread.getName().startsWith("okhttp-dispatch-")) + .map(Thread::getThreadGroup) + .filter(group -> group instanceof BusinessLogsExporterBoundary).forEach(ownedGroups::add); + assertThat(ownedGroups).hasSize(1); + assertThat(Thread.getDefaultUncaughtExceptionHandler()).isSameAs(previousHandler); + }); + org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(Thread.getAllStackTraces().keySet()) + .noneMatch(thread -> ownedGroups.contains(thread.getThreadGroup()) && thread.getName().startsWith("okhttp-dispatch-"))); + assertThat(Thread.getDefaultUncaughtExceptionHandler()).isSameAs(previousHandler); + org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll()).contains("Business Logs telemetry failed")); + // OutputCapture covers System.out/System.err, including the shipped text/async JSON appenders. + assertThat(output.getOut()).doesNotContain(CREDENTIAL, "Exception in thread", "ProtocolException", "IllegalStateException"); + assertThat(output.getErr()).doesNotContain(CREDENTIAL, "Exception in thread", "ProtocolException", "IllegalStateException"); + } + } + private static void exportFailure(String endpoint) { new ApplicationContextRunner().withUserConfiguration(BusinessLogsConfiguration.class) .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsExporterBoundaryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsExporterBoundaryTest.java new file mode 100644 index 000000000..47b560d5e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsExporterBoundaryTest.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.observability.business; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BusinessLogsExporterBoundaryTest { + @Test + void onlyOwnedDispatchersAreContainedAndPreviousDefaultHandlerIsUnchanged() throws Exception { + var previous = Thread.getDefaultUncaughtExceptionHandler(); + var handled = new CopyOnWriteArrayList(); + var failures = new CopyOnWriteArrayList(); + Thread.UncaughtExceptionHandler sentinel = (thread, failure) -> { handled.add(thread); failures.add(failure); }; + Thread.setDefaultUncaughtExceptionHandler(sentinel); + try { + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + ThreadGroup group = BusinessLogsExporterBoundary.build(() -> Thread.currentThread().getThreadGroup(), metrics); + var failure = new IllegalStateException("SYNTHETIC-1009-UNCAUGHT-PLACEHOLDER"); + Thread owned = new Thread(group, () -> { throw failure; }, "okhttp-dispatch-1"); + // Same dispatcher name outside the private group must NOT be swallowed. + Thread unrelated = new Thread(() -> { throw failure; }, "okhttp-dispatch-1"); + // Even inside the private group, other thread kinds retain normal default handling. + Thread otherKind = new Thread(group, () -> { throw failure; }, "unrelated-worker"); + for (Thread thread : java.util.List.of(owned, unrelated, otherKind)) { + thread.start(); thread.join(5000); assertThat(thread.isAlive()).isFalse(); + } + assertThat(handled).containsExactly(unrelated, otherKind); + assertThat(failures).containsExactly(failure, failure); + assertThat(metrics.errors.count()).isEqualTo(1); + assertThat(Thread.getDefaultUncaughtExceptionHandler()).isSameAs(sentinel); + } finally { Thread.setDefaultUncaughtExceptionHandler(previous); } + } + + @Test + void constructionFailureIsFixedTextWithoutRawCauseAndInterruptionIsRestored() { + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + Thread.currentThread().interrupt(); + try { + assertThatThrownBy(() -> BusinessLogsExporterBoundary.build(() -> { + throw new IllegalArgumentException("SYNTHETIC-1009-CONSTRUCTION-PLACEHOLDER"); + }, metrics)).isInstanceOf(IllegalStateException.class) + .hasMessage("Business Logs exporter initialization failed").hasNoCause(); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } finally { Thread.interrupted(); } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java index cfbbfc845..22d182dc8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.observability.business; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillBusinessEvent; import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.namespace.*; import com.iflytek.skillhub.domain.review.*; @@ -17,6 +18,7 @@ import com.iflytek.skillhub.storage.ObjectStorageService; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.logs.data.LogRecordData; import java.time.Clock; import java.time.Instant; import java.util.List; @@ -92,6 +94,15 @@ class BusinessLogsProducerPostgresTest { @Bean BusinessEventListener listener(BusinessLogsPipeline pipeline, BusinessLogsMetrics metrics) { return new BusinessEventListener(new BusinessEventMapper(new BusinessLogsProperties()), pipeline, metrics); } + @Bean PromotionFilterProbe promotionFilterProbe() { return new PromotionFilterProbe(); } + } + + static class PromotionFilterProbe { + private BusinessEventListener delegate; + @org.springframework.context.event.EventListener + public void onEvent(SkillBusinessEvent event) { + if (delegate != null) { delegate.onEvent(event); } + } } @MockBean SecurityScanService scanner; @@ -121,6 +132,7 @@ class BusinessLogsProducerPostgresTest { @Autowired PlatformTransactionManager transactions; @Autowired BusinessLogsPipeline pipeline; @Autowired BusinessLogsTransactionTest.CapturingExporter exporter; + @Autowired PromotionFilterProbe filterProbe; @BeforeEach void setup() { @@ -172,6 +184,7 @@ class BusinessLogsProducerPostgresTest { .containsExactlyInAnyOrderElementsOf(rollback ? List.of() : expected); if (!rollback) { for (var record : exporter.records) { + assertExportedIdentity(path, fixture, record); assertThat(record.getAttributes().toString()).doesNotContain("private reason", "not-exported", "secret", "business-probe", "Never export"); String actor = record.getAttributes().get(AttributeKey.stringKey("user.id")); if (path.equals("download")) { assertThat(actor).isNull(); } @@ -180,7 +193,8 @@ class BusinessLogsProducerPostgresTest { } else { assertThat(actor).isEqualTo(fixture.owner()); } } } - // Persistent state independently proves rollback, not just listener suppression. + // These two representative paths additionally prove persistent rollback state; + // all matrix rows above prove listener suppression, not every database mutation. if (path.equals("private")) { assertThat(((SkillVersionRepository) versions).findById(fixture.uploadedId()).orElseThrow().getStatus()) .isEqualTo(rollback ? SkillVersionStatus.UPLOADED : SkillVersionStatus.PUBLISHED); @@ -192,6 +206,89 @@ class BusinessLogsProducerPostgresTest { System.out.printf("BUSINESS_LOGS producer=%s rollback=%s received=%d expected=%d%n", path, rollback, exporter.records.size(), rollback ? 0 : expected.size()); } + private void assertExportedIdentity(String path, Fixture fixture, LogRecordData record) { + var attributes = record.getAttributes(); + String name = attributes.get(AttributeKey.stringKey("skillhub.event.name")); + Long skillId = fixture.skillId(); + Long namespaceId = ((SkillRepository) skills).findById(skillId).orElseThrow().getNamespaceId(); + Long versionId = switch (path) { + case "auto" -> versions.findBySkillIdAndVersion(skillId, "3.0.0").orElseThrow().getId(); + case "private", "review-approved", "review-rejected" -> fixture.uploadedId(); + case "download", "yank" -> fixture.publishedId(); + default -> null; + }; + if (path.startsWith("promotion") && name.equals("skill.publication.effective")) { + PromotionRequest request = ((PromotionRequestRepository) promotions).findById(fixture.promotionId()).orElseThrow(); + skillId = request.getTargetSkillId(); + versionId = request.getTargetVersionId(); + assertThat(skillId).isNotNull().isNotEqualTo(fixture.skillId()); + assertThat(versionId).isNotNull().isNotEqualTo(fixture.publishedId()); + assertThat(request.getTargetNamespaceId()).isNotEqualTo(namespaceId); + namespaceId = request.getTargetNamespaceId(); + assertThat(((SkillRepository) skills).findById(skillId).orElseThrow().getNamespaceId()).isEqualTo(namespaceId); + assertThat(((SkillVersionRepository) versions).findById(versionId).orElseThrow().getSkillId()).isEqualTo(skillId); + } + assertThat(attributes.get(AttributeKey.longKey("skillhub.skill.id"))).as("%s skill", name).isEqualTo(skillId); + assertThat(attributes.get(AttributeKey.longKey("skillhub.namespace.id"))).as("%s namespace", name).isEqualTo(namespaceId); + assertThat(attributes.get(AttributeKey.longKey("skillhub.version.id"))).as("%s version (or absent)", name).isEqualTo(versionId); + assertThat(attributes.get(AttributeKey.longKey("skillhub.review.id"))) + .isEqualTo(name.startsWith("skill.review.") ? fixture.reviewId() : null); + assertThat(attributes.get(AttributeKey.longKey("skillhub.promotion.id"))) + .isEqualTo(name.startsWith("skill.promotion.") ? fixture.promotionId() : null); + String source = switch (path) { + case "auto" -> "auto_publish"; + case "private" -> "private_confirm"; + case "review-approved", "review-rejected" -> "review"; + case "promotion-initial", "promotion-update", "promotion-rejected" -> "promotion"; + case "archive", "unarchive", "yank" -> "governance"; + case "visibility-narrow", "visibility-expanded" -> "visibility"; + case "download" -> "download"; + default -> throw new AssertionError(path); + }; + assertThat(attributes.get(AttributeKey.stringKey("skillhub.source"))).isEqualTo(source); + assertThat(attributes.get(AttributeKey.stringKey("skillhub.visibility"))) + .isEqualTo(path.equals("private") || path.equals("visibility-narrow") ? "PRIVATE" : "PUBLIC"); + assertThat(attributes.get(AttributeKey.stringKey("skillhub.status.old"))) + .isEqualTo(path.equals("archive") ? "ACTIVE" : path.equals("unarchive") ? "ARCHIVED" : null); + assertThat(attributes.get(AttributeKey.stringKey("skillhub.status.new"))) + .isEqualTo(path.equals("archive") ? "ARCHIVED" : path.equals("unarchive") ? "ACTIVE" : null); + assertThat(attributes.get(AttributeKey.stringKey("skillhub.visibility.old"))) + .isEqualTo(path.equals("visibility-narrow") ? "PUBLIC" : path.equals("visibility-expanded") ? "PRIVATE" : null); + assertThat(attributes.get(AttributeKey.stringKey("skillhub.visibility.new"))) + .isEqualTo(path.equals("visibility-narrow") ? "PRIVATE" : path.equals("visibility-expanded") ? "PUBLIC" : null); + } + + static Stream promotionFilters() { + return Stream.of("promotion-initial", "promotion-update") + .flatMap(path -> Stream.of(Arguments.of(path, true), Arguments.of(path, false))); + } + + @ParameterizedTest(name = "{0}, sourceOnly={1}") + @MethodSource("promotionFilters") + void promotionNamespaceFilterRetainsOnlySourceDecisionOrTargetPublication(String path, boolean sourceOnly) { + Fixture fixture = fixture(path); + PromotionRequest request = ((PromotionRequestRepository) promotions).findById(fixture.promotionId()).orElseThrow(); + Long sourceNamespace = ((SkillRepository) skills).findById(fixture.skillId()).orElseThrow().getNamespaceId(); + assertThat(request.getTargetNamespaceId()).isNotEqualTo(sourceNamespace); + var properties = new BusinessLogsProperties(); + properties.setNamespaceAllowlist(Set.of(sourceOnly ? sourceNamespace : request.getTargetNamespaceId())); + var captured = new BusinessLogsTransactionTest.CapturingExporter(); + var metrics = new BusinessLogsMetrics(new SimpleMeterRegistry()); + try (var filtered = new BusinessLogsPipeline(properties, Resource.empty(), captured, metrics)) { + filterProbe.delegate = new BusinessEventListener(new BusinessEventMapper(properties), filtered, metrics); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + promoting.approvePromotion(fixture.promotionId(), fixture.reviewer(), "checked", Set.of("SUPER_ADMIN"), Set.of()); + assertThat(captured.records).isEmpty(); + }); + filtered.forceFlush().join(5, TimeUnit.SECONDS); + assertThat(captured.records).singleElement().satisfies(record -> { + assertThat(record.getAttributes().get(AttributeKey.stringKey("skillhub.event.name"))) + .isEqualTo(sourceOnly ? "skill.promotion.approved" : "skill.publication.effective"); + assertExportedIdentity(path, fixture, record); + }); + } finally { filterProbe.delegate = null; } + } + private List expected(String path) { return switch (path) { case "auto", "private" -> List.of("skill.publication.effective"); From 79f82489f8464af9d2e170eb73b5078cd05049b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= <258399167+xiaoyaner0201@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:22:20 +0800 Subject: [PATCH 07/21] fix(observability): scope SDK diagnostic suppression to the business exporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the global io.opentelemetry.exporter OFF rule with a Logback TurboFilter that drops OTel/OkHttp diagnostics only on threads of the business exporter's private ThreadGroup. Existing Trace exporter errors (e.g. 401) keep their original diagnostics whether business Logs are disabled or enabled; business receiver responses and credentials stay out of application output. Adds Trace 401 regression cases for Logs disabled and Logs+Trace coexistence in text and JSON formats. Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com> --- docs/business-event-logs.md | 8 +- .../acceptance.md | 14 ++++ .../add-otlp-business-event-logs/design.md | 6 +- .../specs/business-event-logs/spec.md | 3 +- .../BusinessLogsDiagnosticsFilter.java | 28 +++++++ .../src/main/resources/logback-spring.xml | 8 +- .../business/BusinessLogsDiagnosticsTest.java | 80 +++++++++++++++++-- 7 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsFilter.java diff --git a/docs/business-event-logs.md b/docs/business-event-logs.md index 01d8cbfdd..2188caa97 100644 --- a/docs/business-event-logs.md +++ b/docs/business-event-logs.md @@ -150,13 +150,13 @@ Micrometer names on the existing protected Actuator metrics surface (Prometheus | `skillhub.business.logs.export.failures` | Batches whose SDK export fails/throws, exceeds the export budget, or is still outstanding at close / 最终失败、超时或关闭时仍未结束的批次 | | `skillhub.business.logs.exported` | Records in SDK-successful batches; may include receiver partial rejections / SDK 成功记录,含部分拒收盲区 | -No per-user/skill/namespace metric tags. Adapter diagnostics are fixed text, at most once per minute per pipeline, without exception messages, headers or event payloads: `Business Logs telemetry failed; inspect skillhub.business.logs metrics and receiver health`. Shipped `logback-spring.xml` sets `io.opentelemetry.exporter` and `io.opentelemetry.sdk.logs` to OFF by default, including their child loggers routed from JUL by Spring Boot. Raw SDK diagnostics can echo receiver error bodies or transport exception text containing credentials; throttling or ERROR level alone is NOT safe. This also silences shared OTel exporter diagnostics for Trace; Trace export behavior is unchanged. Custom logging configurations must retain these OFF rules. / 指标无高基数标签;适配器每分钟最多一次固定文字。默认关闭 OTel exporter 与 sdk.logs 原始诊断(包括子记录器及 JUL 桥接),其响应或异常文本可能回显凭证,ERROR 级别也不安全;同时静默共享 Trace exporter 诊断但不改变导出。自定义日志配置需保留这些规则。 +No per-user/skill/namespace metric tags. Adapter diagnostics are fixed text, at most once per minute per pipeline, without exception messages, headers or event payloads: `Business Logs telemetry failed; inspect skillhub.business.logs metrics and receiver health`. Raw SDK diagnostics can echo receiver error bodies or transport exception text containing credentials; throttling or ERROR level alone is NOT safe. Shipped `logback-spring.xml` therefore installs `BusinessLogsDiagnosticsFilter`, which drops `io.opentelemetry.*` / `okhttp3.*` events (including those routed from JUL by Spring Boot) only on the business exporter's own network threads, and sets `io.opentelemetry.sdk.logs` (used only by this pipeline) to OFF. Shared exporter loggers keep their normal levels, so existing Trace export diagnostics such as `Failed to export spans ... 401` are unchanged whether business Logs are on or off. Custom logging configurations must retain the filter and the `sdk.logs` OFF rule. / 指标无高基数标签;适配器每分钟最多一次固定文字。原始 SDK 诊断可能回显凭证,ERROR 级别也不安全;默认配置只在业务 exporter 自己的网络线程上丢弃 OTel/OkHttp 诊断(含 JUL 桥接),并关闭仅本管道使用的 sdk.logs。共享 exporter 记录器级别不变,Trace 导出错误提示无论业务 Logs 开关均保持原样。自定义日志配置需保留该过滤器和 sdk.logs 规则。 -Logger OFF alone does not contain OTel 1.31's uncaught asynchronous callback failures (for example, an HTTP 401 with a malformed chunked body). The production business exporter is constructed in a private ThreadGroup: only its `okhttp-dispatch-*` threads' uncaught exceptions are discarded without rendering or forwarding the throwable. This increments `errors` and retains the same fixed warning; the existing completion deadline separately counts one failed batch and recovers permits. The JVM default handler is never changed. Other threads, including same-named threads outside that group and shared OkHttp housekeeping threads, delegate to their normal parent/default handler unchanged. Pipeline shutdown cancels its dispatcher executor; containment stays attached until its last callback exits, then no owned dispatcher remains. An inert group can outlive the pipeline for shared OkHttp housekeeping, but delegates those exceptions and only weakly references metrics. / 仅关闭 logger 不足以阻止 1.31 异步回调异常直写 stderr。业务 exporter 在私有线程组内构建,只隔离该组导出 dispatcher 的未捕获异常,计 errors 并保留固定告警;预算到期另计一批失败并恢复许可。不改 JVM 默认 handler,其它线程仍按原处理链执行。关闭时取消导出线程,隔离保留至回调退出;共享 housekeeping 可保留空闲边界组,但不受抑制且不强引用指标。 +Log filtering alone does not contain OTel 1.31's uncaught asynchronous callback failures (for example, an HTTP 401 with a malformed chunked body). The production business exporter is constructed in a private ThreadGroup: only its `okhttp-dispatch-*` threads' uncaught exceptions are discarded without rendering or forwarding the throwable. This increments `errors` and retains the same fixed warning; the existing completion deadline separately counts one failed batch and recovers permits. The JVM default handler is never changed. Other threads, including same-named threads outside that group and shared OkHttp housekeeping threads, delegate to their normal parent/default handler unchanged. Pipeline shutdown cancels its dispatcher executor; containment stays attached until its last callback exits, then no owned dispatcher remains. An inert group can outlive the pipeline for shared OkHttp housekeeping, but delegates those exceptions and only weakly references metrics. / 仅过滤日志不足以阻止 1.31 异步回调异常直写 stderr。业务 exporter 在私有线程组内构建,只隔离该组导出 dispatcher 的未捕获异常,计 errors 并保留固定告警;预算到期另计一批失败并恢复许可。不改 JVM 默认 handler,其它线程仍按原处理链执行。关闭时取消导出线程,隔离保留至回调退出;共享 housekeeping 可保留空闲边界组,但不受抑制且不强引用指标。 -This safe-diagnostics boundary covers this dedicated business Logs pipeline with the shipped logger OFF defaults and the verified managed 1.31 sender. It is not process-wide exception redaction: unrelated Trace/agent exporters, application failures, custom logging/SDK overrides, agents intercepting exceptions, heap dumps and receiver-side logs are outside it. SDK upgrades must reverify dispatcher ownership/naming and the captured stdout/stderr tests; no BOM upgrade is included here. / 安全诊断范围限于默认日志规则和已验证的 1.31 业务管道,不是整个 JVM 的异常脱敏;其它 exporter、应用异常、配置覆盖、Agent、堆转储和接收端日志不在范围内。升级 SDK 必须重验线程归属与输出回归。 +This safe-diagnostics boundary covers this dedicated business Logs pipeline with the shipped diagnostics filter defaults and the verified managed 1.31 sender. It is not process-wide exception redaction: unrelated Trace/agent exporters keep their own (unchanged) diagnostics, and application failures, custom logging/SDK overrides, agents intercepting exceptions, heap dumps and receiver-side logs are outside it. SDK upgrades must reverify that the sender reports results on dispatcher threads owned by the private group, plus the captured stdout/stderr and Trace-coexistence tests; no BOM upgrade is included here. / 安全诊断范围限于默认日志规则和已验证的 1.31 业务管道,不是整个 JVM 的异常脱敏;Trace 等其它 exporter 保留各自原有诊断,应用异常、配置覆盖、Agent、堆转储和接收端日志不在范围内。升级 SDK 必须重验回调线程归属、输出回归与 Trace 共存测试。 -Troubleshoot using the protected Actuator counters above, this fixed warning, receiver-side status/health, and independent DNS/network/TLS/auth checks with secret-safe tools. If raw SDK diagnostics are indispensable, first remove production credentials or reproduce with synthetic credentials in an isolated environment, restrict log access/retention, and knowingly accept disclosure risk. Temporarily set the exact Spring property `logging.level.io.opentelemetry.exporter.internal.http.HttpExporter=DEBUG` (e.g. a deployment-owned config file or `SPRING_APPLICATION_JSON`); `logging.level.io.opentelemetry.sdk.logs=DEBUG` opts into Logs processor diagnostics separately. Logger names are case-sensitive; do not rely on relaxed uppercase env names for the class logger. Restore both package defaults to OFF and remove any child-logger override after diagnosis, then restart; do not share unredacted SDK output. Raising root logging alone does not override explicit package OFF. / 排障优先使用受保护指标、固定告警及接收端健康,不输出 Secret。确需 SDK 原始细节时,在隔离环境用假凭证复现,限制访问与保留并明确接受泄露风险;临时提高精确日志属性(类名区分大小写),结束后恢复 OFF、删除子记录器覆盖并重启,勿分享未脱敏输出。 +Troubleshoot using the protected Actuator counters above, this fixed warning, receiver-side status/health, and independent DNS/network/TLS/auth checks with secret-safe tools. If raw SDK diagnostics are indispensable, first remove production credentials or reproduce with synthetic credentials in an isolated environment, restrict log access/retention, and knowingly accept disclosure risk. Business exporter raw diagnostics are dropped by the filter regardless of logger level; reproduce with a deployment-owned logging config that omits `BusinessLogsDiagnosticsFilter` (and, for processor diagnostics, sets `io.opentelemetry.sdk.logs` to DEBUG), then restore the shipped config and restart; do not share unredacted SDK output. / 排障优先使用受保护指标、固定告警及接收端健康,不输出 Secret。确需 SDK 原始细节时,在隔离环境用假凭证复现,限制访问与保留并明确接受泄露风险;业务 exporter 的原始诊断不受日志级别影响,需用部署方自有日志配置临时去掉过滤器复现,结束后恢复默认配置并重启,勿分享未脱敏输出。 | Symptom / 现象 | Check / 排查 | | --- | --- | diff --git a/openspec/changes/add-otlp-business-event-logs/acceptance.md b/openspec/changes/add-otlp-business-event-logs/acceptance.md index 2ff94f91a..d0987a53e 100644 --- a/openspec/changes/add-otlp-business-event-logs/acceptance.md +++ b/openspec/changes/add-otlp-business-event-logs/acceptance.md @@ -86,6 +86,20 @@ An initial RED fixture run (`red-chunked-diagnostics.log`) failed 2 startup asse Scope: containment is private to this verified managed business sender, with shipped SDK logger OFF defaults. Non-dispatcher housekeeping delegates unchanged; its inert group may remain alive but weakly references metrics. No global handler is installed/restored; containment lasts through owned callback exit. Unrelated exporters/application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs remain outside the guarantee. SDK upgrades require revalidation. Broad backend/base comparisons, packaging and docs-site build were not rerun in Repair 2; previous limitations below remain unchanged. `git diff --check` passes; owned containers/networks/volumes are cleaned up. / 隔离非全 JVM 脱敏,版本与默认日志范围及剩余部署责任详见设计和运维文档;本轮未重跑全量后端、打包或文档站构建,不冒称消除既有环境限制。 +## Repair 3 — maintainer review (PR #1029, `bd30ac1`) + +Finding: the global `io.opentelemetry.exporter = OFF` rule also hid existing Trace exporter errors when business Logs were disabled (reviewer reproduced 2 Trace 401s with 0 error lines vs. 2 `Failed to export spans` on main's logback config). / 维护者复现:全局 OFF 在业务 Logs 关闭时也屏蔽了既有 Trace 导出错误。 + +Change: removed the global exporter OFF; added `BusinessLogsDiagnosticsFilter` (Logback TurboFilter) that drops `io.opentelemetry.*` / `okhttp3.*` events only on threads of the business exporter's private `BusinessLogsExporterBoundary` group; kept `io.opentelemetry.sdk.logs` OFF (business pipeline only). / 去掉全局 OFF,改为只在业务 exporter 私有线程组上过滤 SDK 诊断。 + +| Scenario | Assertion | RED → GREEN | +| --- | --- | --- | +| Logs disabled + Trace 401 | Real `OtlpHttpSpanExporter` → local receiver returns 401 on `/v1/traces`; captured output contains `Failed to export spans`, `401` and the receiver message, text and JSON formats | RED with `bd30ac1` logback (await timeout, 0 lines); GREEN | +| Logs enabled + Trace 401 coexist | Business Logs 401 echoing synthetic credential and Trace 401 in one logging setup: Trace diagnostic present; credential and `Failed to export logs` absent; fixed business warning present | RED with `bd30ac1` logback (Trace line missing); GREEN | +| Sensitivity of the filter | Same tests with the global OFF removed but no filter: existing credential tests fail (6 failures: credential / `Failed to export logs` printed) | Proves the filter, not the logger level, provides the business containment | + +Focused command (Repair 1 set, JDK 21): **311 tests, 0 failures, 0 errors, 0 skips** (adds 4 Trace-coexistence cases). Broad suite, deployment contracts and real Collector run were not rerun in Repair 3; no deployment setting changed. / 定向 311 项全部通过;本轮未重跑全量与部署契约。 + ## Broad suite and environment limits Broad `make test-backend` runs are NOT green. Initial candidate: 2732 tests, 2 failures, 1 error, 30 skips. Final candidate broad run: 2762 tests, 3 failures, 11 errors, 30 skips. Frozen base: 2712 tests, 2 failures, 2 errors, 30 skips. Failures are in GitHub integration tests; exact failing methods are retained in the count JSONs. Four methods occur in both the final candidate's and base's broad failure sets (Go executor, credential privacy, two budget cases), but the full failure sets and error modes vary. Do NOT infer that every candidate failure was proven pre-existing. Subsequent identical five-class GitHub focused commands PASS all 37 tests on BOTH the candidate and frozen base. An independent SQL/host clock probe found the Docker database clock ahead of the host in every sample, consistent with timing-sensitive eligibility checks, but it is NOT asserted as the proven cause of every failure. No clock/global configuration was changed. The authorized detached base worktree was removed. / 全量检查不是绿色;基线与 candidate 有四个相同失败方法,但集合及错误形式不同,不冒称全部故障已证实为基线问题。随后相同五类 GitHub 定向测试在两边均 37 项通过。独立探测发现 Docker 数据库时钟领先宿主机,与时间筛选类故障相符,但未证明所有故障根因;未改系统时钟/全局配置。基线临时工作树已移除。 diff --git a/openspec/changes/add-otlp-business-event-logs/design.md b/openspec/changes/add-otlp-business-event-logs/design.md index bf9344d7b..3ff9f8493 100644 --- a/openspec/changes/add-otlp-business-event-logs/design.md +++ b/openspec/changes/add-otlp-business-event-logs/design.md @@ -16,17 +16,17 @@ Occurrence is `Instant.now()` immediately before producer publication, not the i Standard SDK batching is retained. A nonwaiting semaphore around processor admission conservatively bounds queued PLUS in-flight records to `max-queue-size`; SDK queue capacity is at least that large, so accepted records cannot hit its hidden overflow boundary. A measured exporter owns a first-completion-wins result: SDK completion or the existing `(timeout + retry-max-backoff) * retry-max-attempts + 1s` deadline releases permits and updates counters exactly once. This handles OTel 1.31 async response-body exceptions that abandon their result; deadline/close completion counts one failed batch and late SDK completion is ignored. One daemon scheduler removes canceled deadlines; close stops it and fails outstanding guards. It bounds local accounting, not remote delivery/cancellation. This supplies exact admission/full metrics without reflection or a custom network queue/retry engine. SDK MeterProvider is explicitly noop; exporter health uses the existing Micrometer registry without global registration. -Shipped Logback configuration sets `io.opentelemetry.exporter` and `io.opentelemetry.sdk.logs` (and inherited child loggers) OFF. These JUL diagnostics are bridged by Boot and may otherwise disclose arbitrary receiver Status messages or transport exception text echoing headers; ERROR-only is insufficient. SkillHub's fixed warning (at most once/minute/pipeline) and Micrometer counters remain the operator signal. This also suppresses shared Trace exporter diagnostics, not its exports. Custom logging must retain these defaults; a deliberate case-sensitive `logging.level` override can temporarily enable unsafe details, preferably only with synthetic credentials and restricted logs, then restore OFF/remove child overrides. The operator guide documents that troubleshooting path. +OTel exporter diagnostics are bridged from JUL by Boot and may disclose arbitrary receiver Status messages or transport exception text echoing headers; ERROR-only is insufficient. The shipped Logback configuration installs `BusinessLogsDiagnosticsFilter`, a TurboFilter that denies `io.opentelemetry.*` / `okhttp3.*` events only when the current thread belongs to the business exporter's private `BusinessLogsExporterBoundary` group (the managed 1.31 sender reports results on those dispatcher threads), and sets `io.opentelemetry.sdk.logs`, used only by this pipeline, OFF. Shared exporter loggers keep their configured level, so Trace (`otel-sdk`) export failures such as HTTP 401 keep their original `Failed to export spans` diagnostics whether business Logs are disabled or enabled. SkillHub's fixed warning (at most once/minute/pipeline) and Micrometer counters remain the business Logs operator signal. Custom logging must retain the filter and the `sdk.logs` rule; the operator guide documents the isolated troubleshooting path. ### Scoped asynchronous exception boundary (Repair 2) -The 1.31 `HttpExporter` can throw from OkHttp's callback while parsing a malformed non-2xx response body; logger OFF and the accounting deadline do not stop the JVM uncaught handler printing that throwable to stderr. `BusinessLogsExporterBoundary` constructs only this exporter on a short-lived initializer in a private ThreadGroup. Its uncaught handler suppresses only threads whose group is that exact object AND whose restored factory name starts `okhttp-dispatch-`. It never inspects/formats/forwards the throwable, increments the existing adapter error counter and emits only the fixed warning; the completion guard independently records one failed batch and releases permits at its deadline. Initializer failures pass through a FutureTask and become a fixed cause-free startup exception; an interrupted caller finishes ownership transfer and restores its interrupt flag. Failure while constructing the pipeline cancels the built exporter and throws fixed text without the raw cause. +The 1.31 `HttpExporter` can throw from OkHttp's callback while parsing a malformed non-2xx response body; log filtering and the accounting deadline do not stop the JVM uncaught handler printing that throwable to stderr. `BusinessLogsExporterBoundary` constructs only this exporter on a short-lived initializer in a private ThreadGroup. Its uncaught handler suppresses only threads whose group is that exact object AND whose restored factory name starts `okhttp-dispatch-`. It never inspects/formats/forwards the throwable, increments the existing adapter error counter and emits only the fixed warning; the completion guard independently records one failed batch and releases permits at its deadline. Initializer failures pass through a FutureTask and become a fixed cause-free startup exception; an interrupted caller finishes ownership transfer and restores its interrupt flag. Failure while constructing the pipeline cancels the built exporter and throws fixed text without the raw cause. Why this rather than a global handler or new HTTP stack: `javap` against the actual managed 1.31 artifacts confirms the public `OtlpHttpLogRecordExporterBuilder` has NO transport/executor/thread-factory setter. `OkHttpUtil.newDispatcher()` constructs a per-exporter executor with `DaemonThreadFactory("okhttp-dispatch")`; its `Executors.defaultThreadFactory()` captures the constructing thread's group. This JDK ThreadGroup mechanism needs no SDK-private imports/reflection, dependency or BOM change and retains standard HTTP/retry behavior. The narrow version-specific assumption is dispatcher construction/group capture and restored naming, protected by the real receiver regression. Future SDK upgrades must rerun it and revisit the boundary, not assume compatibility. There is no process-wide installation or restoration: the default handler is never set by production code, no thread scans mutate handlers, and multiple pipelines own independent groups. The scope is attached at exporter construction, before any export, and retired with that exporter's dispatcher executor at shutdown. It MUST remain attached to late callbacks even after close returns rather than exposing a teardown race. Group members other than dispatchers delegate through `super.uncaughtException` unchanged. OkHttp may first initialize shared housekeeping in this group; such threads are NOT suppressed and can keep the otherwise inert group alive. Its metrics reference is weak so this cannot retain the closed pipeline/registry. Java 21 reclaims groups when unreachable; no deprecated `ThreadGroup.destroy()` or global cleanup is needed. Tests assert actual production dispatchers use the private group, vanish after close, and never change the previous default handler. Guard tests additionally throw on an unrelated same-named thread outside the group and a non-dispatcher inside it: both reach the previous default handler with the original throwable. -Together with the shipped logger OFF rules and synchronous cause-free catches, this prevents raw business-exporter failure text in application stdout/stderr/logs, including malformed chunked 401 responses. It is deliberately NOT process-wide redaction: unrelated Trace/agent exporters, application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs are outside the guarantee. No previously installed handler is silenced for those threads. The existing shared Trace logger suppression remains the disclosed operational tradeoff; the new exception boundary adds no global handler side effect. +Together with the shipped diagnostics filter and synchronous cause-free catches, this prevents raw business-exporter failure text in application stdout/stderr/logs, including malformed chunked 401 responses. It is deliberately NOT process-wide redaction: unrelated Trace/agent exporters, application failures, explicit logging/SDK overrides, exception-intercepting agents, heap dumps and receiver logs are outside the guarantee. No previously installed handler is silenced for those threads. Trace exporter diagnostics are not suppressed; the new exception boundary adds no global handler side effect. A single mapper allows only known event classes, fixed sources/actor types, scalar IDs, enums and bounded user ID. The SDK also limits 16 attributes and 256 code units. Three Resource strings are separately bounded. Configuration has hard count/duration/header/list bounds. Admitted pipeline memory is bounded by record count and fixed record shape, not a promised byte-accurate or zero-allocation budget. Before commit, transaction synchronizations retain one bounded mapped snapshot per dispatched event outside the SDK admission window; callers must not wrap unbounded bulk work in one transaction. This is not a process-wide heap cap. Saturation latency and receiver container limits are measured in tests; this is not a production throughput benchmark. diff --git a/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md b/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md index 4725b1791..b04f4b9ef 100644 --- a/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md +++ b/openspec/changes/add-otlp-business-event-logs/specs/business-event-logs/spec.md @@ -76,7 +76,8 @@ A single mapper SHALL export only fixed documented scalar fields, including IDs - **WHEN** MDC and OTel Baggage contain distinct sensitive sentinels - **THEN** neither keys nor values SHALL appear in exported attributes, body or resource - **WHEN** receiver Status messages or transport exceptions echo transport credentials -- **THEN** shipped logging SHALL suppress raw `io.opentelemetry.exporter` and `io.opentelemetry.sdk.logs` diagnostics, including child loggers, by default +- **THEN** shipped logging SHALL suppress raw OTel/OkHttp diagnostics emitted on the business exporter's own threads and `io.opentelemetry.sdk.logs` diagnostics by default +- **AND** existing Trace exporter diagnostics SHALL remain unchanged whether business Logs are disabled or enabled - **AND** fixed-text rate-bounded SkillHub diagnostics and counters SHALL remain available - **AND** operator documentation SHALL warn that explicitly raising SDK logging opts into possible credential disclosure diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsFilter.java new file mode 100644 index 000000000..7b77ccb2c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsFilter.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.observability.business; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.turbo.TurboFilter; +import ch.qos.logback.core.spi.FilterReply; +import org.slf4j.Marker; + +/** + * Drops raw OTel/OkHttp diagnostics emitted on the business exporter's own network threads. + * Those messages can echo receiver bodies or transport exception text containing credentials. + * The managed 1.31 sender reports results on its dispatcher threads, which are all created in + * {@link BusinessLogsExporterBoundary}; Trace and other exporters share the same logger names but + * run on other threads, so their diagnostics keep the logging configuration's normal behavior. + */ +public final class BusinessLogsDiagnosticsFilter extends TurboFilter { + @Override + public FilterReply decide(Marker marker, Logger logger, Level level, String format, Object[] params, + Throwable throwable) { + if (logger == null) { return FilterReply.NEUTRAL; } + String name = logger.getName(); + if ((name.startsWith("io.opentelemetry.") || name.startsWith("okhttp3.")) + && Thread.currentThread().getThreadGroup() instanceof BusinessLogsExporterBoundary) { + return FilterReply.DENY; + } + return FilterReply.NEUTRAL; + } +} diff --git a/server/skillhub-app/src/main/resources/logback-spring.xml b/server/skillhub-app/src/main/resources/logback-spring.xml index 803ac9180..5217a0491 100644 --- a/server/skillhub-app/src/main/resources/logback-spring.xml +++ b/server/skillhub-app/src/main/resources/logback-spring.xml @@ -45,10 +45,10 @@ - - + + diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java index 4b88b7beb..19296d80a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java @@ -36,15 +36,83 @@ class BusinessLogsDiagnosticsTest { environment.getPropertySources().addFirst(new MapPropertySource("diagnostic-test", java.util.Map.of("skillhub.observability.log-format", format))); logging.initialize(new LoggingInitializationContext(environment), "classpath:logback-spring.xml", null); - for (String name : java.util.List.of("io.opentelemetry.exporter.internal.http.HttpExporter", - "io.opentelemetry.exporter.sender.okhttp.internal.OkHttpHttpSender", - "io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor")) { - org.slf4j.LoggerFactory.getLogger(name); - assertThat(logging.getLoggerConfiguration(name).getEffectiveLevel()) - .isEqualTo(org.springframework.boot.logging.LogLevel.OFF); + String processor = "io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor"; + org.slf4j.LoggerFactory.getLogger(processor); + assertThat(logging.getLoggerConfiguration(processor).getEffectiveLevel()) + .isEqualTo(org.springframework.boot.logging.LogLevel.OFF); + } + + @ParameterizedTest + @ValueSource(strings = {"text", "json"}) + void businessLogsDisabledKeepsExistingTraceExportFailureDiagnostics(String format, CapturedOutput output) + throws Exception { + configureShippedLogging(format); + try (var receiver = new UnauthorizedReceiver("/v1/traces")) { + exportSpanFailure(receiver.endpoint()); + assertThat(receiver.requests.get()).isEqualTo(1); + org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll()) + .contains("Failed to export spans", "401", "trace-receiver-rejected")); } } + @ParameterizedTest + @ValueSource(strings = {"text", "json"}) + void businessLogsEnabledKeepsTraceDiagnosticsAndHidesBusinessReceiverResponse(String format, + CapturedOutput output) throws Exception { + configureShippedLogging(format); + try (var traces = new UnauthorizedReceiver("/v1/traces"); + var logs = new UnauthorizedReceiver("/v1/logs")) { + exportFailure(logs.endpoint()); + exportSpanFailure(traces.endpoint()); + assertThat(logs.requests.get()).isEqualTo(1); + assertThat(traces.requests.get()).isEqualTo(1); + org.awaitility.Awaitility.await().untilAsserted(() -> assertThat(output.getAll()) + .contains("Business Logs telemetry failed", "Failed to export spans")); + assertThat(output.getAll()).doesNotContain(CREDENTIAL, "Failed to export logs"); + } + } + + private static void exportSpanFailure(String endpoint) { + var exporter = io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter.builder() + .setEndpoint(endpoint).setTimeout(java.time.Duration.ofSeconds(2)).build(); + var provider = io.opentelemetry.sdk.trace.SdkTracerProvider.builder() + .addSpanProcessor(io.opentelemetry.sdk.trace.export.SimpleSpanProcessor.create(exporter)).build(); + try { + provider.get("trace-diagnostics-test").spanBuilder("trace-401").startSpan().end(); + assertThat(provider.forceFlush().join(5, TimeUnit.SECONDS).isSuccess()).isFalse(); + } finally { provider.shutdown().join(5, TimeUnit.SECONDS); } + } + + /** Receiver that rejects with 401 and echoes a marker (the synthetic credential for /v1/logs). */ + private static final class UnauthorizedReceiver implements AutoCloseable { + private final HttpServer server; + private final String path; + final java.util.concurrent.atomic.AtomicInteger requests = new java.util.concurrent.atomic.AtomicInteger(); + + UnauthorizedReceiver(String path) throws java.io.IOException { + this.path = path; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext(path, exchange -> { + exchange.getRequestBody().readAllBytes(); + requests.incrementAndGet(); + String echoed = path.endsWith("/logs") ? CREDENTIAL : "trace-receiver-rejected"; + byte[] text = ("auth failed for Bearer " + echoed).getBytes(StandardCharsets.UTF_8); + byte[] body = new byte[text.length + 4]; + body[0] = 0x08; body[1] = 0x10; body[2] = 0x12; body[3] = (byte) text.length; + System.arraycopy(text, 0, body, 4, text.length); + exchange.getResponseHeaders().add("Content-Type", "application/x-protobuf"); + exchange.sendResponseHeaders(401, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + } + + String endpoint() { return "http://127.0.0.1:" + server.getAddress().getPort() + path; } + + @Override public void close() { server.stop(0); } + } + @AfterEach void cleanupLogging() { if (logging != null) { From 294410d3245b3959173ce2c53c33a955bf599c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 15:33:44 +0800 Subject: [PATCH 08/21] test: isolate process-global business logs diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- server/skillhub-app/pom.xml | 21 +++++++++++++++++++ .../business/BusinessLogsDiagnosticsTest.java | 8 ++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 8db07a16c..9445615c9 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -168,6 +168,27 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + default-test + + process-global-diagnostics + + + + isolated-diagnostics + test + + process-global-diagnostics + 1 + false + + + + org.springframework.boot spring-boot-maven-plugin diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java index 19296d80a..3f1669529 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsDiagnosticsTest.java @@ -23,7 +23,13 @@ import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import static org.assertj.core.api.Assertions.assertThat; -/** Load the shipped logging config, including JUL-to-SLF4J routing used by Boot. */ +/** + * Load the shipped logging config, including JUL-to-SLF4J routing used by Boot. + * Surefire runs this tag in a dedicated, non-reused JVM: Boot logging/JUL are + * process-global, and raw SDK spans initialize OTel ContextStorage before Boot + * can install its MDC wrapper. Neither can be restored by LoggingSystem.cleanUp(). + */ +@org.junit.jupiter.api.Tag("process-global-diagnostics") @ExtendWith(OutputCaptureExtension.class) class BusinessLogsDiagnosticsTest { private static final String CREDENTIAL = "SYNTHETIC-1009-CREDENTIAL"; From 07d3c31482a4e7a5cd77b36052f4cc64eecb6b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 15:41:08 +0800 Subject: [PATCH 09/21] feat: opt in to protected business metrics in Compose and Helm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- .../skillhub/templates/server-deployment.yaml | 2 + .../skillhub/tests/configuration-contracts.sh | 22 ++++++++ charts/skillhub/values.schema.json | 1 + charts/skillhub/values.yaml | 3 ++ compose.release.yml | 1 + scripts/validate-release-config.sh | 4 ++ .../src/main/resources/application.yml | 2 +- .../metrics/BusinessMetricsSecurityTest.java | 53 +++++++++++++++++++ .../metrics/PrometheusSecurityTest.java | 8 +++ .../policy/RouteSecurityPolicyRegistry.java | 2 + 10 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/BusinessMetricsSecurityTest.java diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index c3443dcfd..1e3ae4306 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -79,6 +79,8 @@ spec: value: {{ $profiles | quote }} - name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED value: {{ .Values.server.suiteReviewWritesEnabled | quote }} + - name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE + value: {{ .Values.server.actuatorExposureInclude | default "health,info" | quote }} {{- $businessLogs := .Values.server.businessLogs | default dict }} - name: SKILLHUB_BUSINESS_LOGS_ENABLED value: {{ $businessLogs.enabled | default false | quote }} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index db3ed740c..c23a04ee2 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -633,3 +633,25 @@ print('Helm application Secret render/rotation contracts passed') PY_SECRET_RENDER echo "Helm configuration contract tests passed" + +# Business Logs and metrics remain opt-in; credentials reference an existing Secret. +render business-default "$CHART_DIR" --show-only templates/server-deployment.yaml >"$TMP_DIR/business-default.yaml" +grep -A1 -F 'name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE' "$TMP_DIR/business-default.yaml" | grep -Fq 'value: "health,info"' +grep -A1 -F 'name: SKILLHUB_BUSINESS_LOGS_ENABLED' "$TMP_DIR/business-default.yaml" | grep -Fq 'value: "false"' +assert_rejected business-no-endpoint --set server.businessLogs.enabled=true +assert_rejected actuator-wildcard --set-string 'server.actuatorExposureInclude=*' +cat >"$TMP_DIR/business-values.yaml" <<'BUSINESS_VALUES' +server: + actuatorExposureInclude: health,info,metrics,prometheus + businessLogs: + enabled: true + endpoint: https://receiver.example.com/v1/logs + headersSecretRef: + name: business-logs-credentials + key: headers +BUSINESS_VALUES +render business-enabled "$CHART_DIR" -f "$TMP_DIR/business-values.yaml" --show-only templates/server-deployment.yaml >"$TMP_DIR/business-enabled.yaml" +grep -A1 -F 'name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'value: "health,info,metrics,prometheus"' +grep -A6 -F 'name: SKILLHUB_BUSINESS_LOGS_HEADERS' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'name: "business-logs-credentials"' +grep -A6 -F 'name: SKILLHUB_BUSINESS_LOGS_HEADERS' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'optional: false' +echo 'Helm business Logs / metrics contracts passed' diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index af42bc723..62ba1bbfc 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -452,6 +452,7 @@ "enabled": { "type": "boolean" }, "replicaCount": { "type": "integer", "minimum": 1 }, "suiteReviewWritesEnabled": { "type": "boolean" }, + "actuatorExposureInclude": { "enum": ["health,info", "health,info,metrics,prometheus"] }, "businessLogs": { "type": "object", "additionalProperties": false, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 9dfd6b47a..3bb76f2c3 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -312,6 +312,9 @@ server: # Server instance supports typed review subjects; fresh non-rolling installs may enable it directly. suiteReviewWritesEnabled: false + # Opt in with health,info,metrics,prometheus. No wildcard exposure. + actuatorExposureInclude: health,info + # Independent of tracing; optional OTLP Logs receiver. Other budgets use server.extraEnv. businessLogs: enabled: false diff --git a/compose.release.yml b/compose.release.yml index c450a7657..848695ecd 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -117,6 +117,7 @@ services: SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:-PT16M} SKILLHUB_BUILTIN_SKILLS_ENABLED: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:-true} SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false} + SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE: ${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:-health,info} SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none} SKILLHUB_BUSINESS_LOGS_ENABLED: ${SKILLHUB_BUSINESS_LOGS_ENABLED:-false} SKILLHUB_BUSINESS_LOGS_ENDPOINT: ${SKILLHUB_BUSINESS_LOGS_ENDPOINT:-} diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 3cf5d3909..256a67480 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -348,6 +348,10 @@ validate_boolean BOOTSTRAP_ADMIN_ENABLED validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO validate_boolean SKILLHUB_BUILTIN_SKILLS_ENABLED validate_boolean SKILLHUB_BUSINESS_LOGS_ENABLED +case "${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:-health,info}" in + health,info|health,info,metrics,prometheus) ;; + *) error "SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE must be health,info or health,info,metrics,prometheus" ;; +esac # Do not echo the endpoint or authentication headers. Full URL/budget validation is at startup. if [ "${SKILLHUB_BUSINESS_LOGS_ENABLED:-false}" = "true" ]; then require_non_empty SKILLHUB_BUSINESS_LOGS_ENDPOINT diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index ac4ce8a6e..2b7383ac3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -357,7 +357,7 @@ management: endpoints: web: exposure: - include: health,info + include: ${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:health,info} endpoint: health: show-details: when-authorized diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/BusinessMetricsSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/BusinessMetricsSecurityTest.java new file mode 100644 index 000000000..559a9931c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/BusinessMetricsSecurityTest.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.metrics; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability +@SpringBootTest(properties = "SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus") +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class BusinessMetricsSecurityTest { + @Autowired MockMvc mockMvc; + @Autowired MeterRegistry registry; + @MockBean NamespaceMemberRepository namespaceMemberRepository; + @MockBean DeviceAuthService deviceAuthService; + + static RequestPostProcessor session(String... roles) { + var principal = new PlatformPrincipal("synthetic-metrics-user", "Metrics test", null, null, "local", Set.of(roles)); + var authorities = java.util.Arrays.stream(roles).map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList(); + return authentication(new UsernamePasswordAuthenticationToken(principal, null, authorities)); + } + + @Test + void optInExposesOnlyMetricsToAuthorizedSessions() throws Exception { + registry.counter("skillhub.business.logs.enqueued").increment(); + for (String path : List.of("/actuator/metrics", "/actuator/metrics/skillhub.business.logs.enqueued", "/actuator/prometheus")) { + mockMvc.perform(get(path).with(session("SUPER_ADMIN"))).andExpect(status().isOk()); + mockMvc.perform(get(path).with(session())).andExpect(status().isForbidden()); + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + mockMvc.perform(get("/actuator/env").with(session("SUPER_ADMIN"))).andExpect(status().isNotFound()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java index 74ed95609..972dda370 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java @@ -30,6 +30,14 @@ class PrometheusSecurityTest { @MockBean private DeviceAuthService deviceAuthService; + @Test + void metricsRemainUnexposedByDefaultEvenForAdmin() throws Exception { + for (String path : java.util.List.of("/actuator/metrics", "/actuator/prometheus")) { + mockMvc.perform(get(path).with(BusinessMetricsSecurityTest.session("SUPER_ADMIN"))) + .andExpect(status().isNotFound()); + } + } + @Test void prometheusEndpointShouldNotBeAnonymous() throws Exception { mockMvc.perform(get("/actuator/prometheus")) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 115454ecb..97772f6e1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -36,6 +36,8 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(null, "/swagger-ui/**"), RouteAuthorizationPolicy.permitAll(null, "/.well-known/**"), RouteAuthorizationPolicy.roles(null, "/actuator/prometheus", "SUPER_ADMIN", "AUDITOR"), + RouteAuthorizationPolicy.roles(null, "/actuator/metrics", "SUPER_ADMIN"), + RouteAuthorizationPolicy.roles(null, "/actuator/metrics/**", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/star"), RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/*/star"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/star"), From d525787076d74ecf441e8a6767ce0e4de19b1357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 15:52:04 +0800 Subject: [PATCH 10/21] feat: add one-click business logs receiver and Collector lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- .env.release.example | 6 ++ business-logs-collector.yaml | 21 ++++ compose.business-logs-collector.yml | 20 ++++ compose.release.business-logs.yml | 10 ++ scripts/runtime.sh | 147 +++++++++++++++++++++++++++- 5 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 business-logs-collector.yaml create mode 100644 compose.business-logs-collector.yml create mode 100644 compose.release.business-logs.yml diff --git a/.env.release.example b/.env.release.example index e7bde2024..cc3c3e225 100644 --- a/.env.release.example +++ b/.env.release.example @@ -80,6 +80,8 @@ SESSION_COOKIE_SECURE=false # Observability defaults require no Collector or tracing backend. # Use json in container deployments when stdout is collected centrally. +# Opt in to protected metrics with health,info,metrics,prometheus; never use '*'. +SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info SKILLHUB_TRACING_MODE=none SKILLHUB_LOG_FORMAT=json SKILLHUB_LOG_ASYNC_QUEUE_SIZE=1024 @@ -94,6 +96,10 @@ SKILLHUB_OTLP_COMPRESSION=gzip # Independent, lossy business analytics. Default off; no receiver required. # Full catalog, Secret injection and Collector query example: docs/business-event-logs.md SKILLHUB_BUSINESS_LOGS_ENABLED=false +# Managed by runtime.sh; unique names per installation on a shared Docker host. +SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED=false +SKILLHUB_BUSINESS_LOGS_NETWORK=skillhub-business-logs +SKILLHUB_BUSINESS_LOGS_PROJECT=skillhub-business-logs SKILLHUB_BUSINESS_LOGS_ENDPOINT= # URL-encoded name=value pairs, comma-separated; inject from deployment Secrets, never commit real values. SKILLHUB_BUSINESS_LOGS_HEADERS= diff --git a/business-logs-collector.yaml b/business-logs-collector.yaml new file mode 100644 index 000000000..479dacb65 --- /dev/null +++ b/business-logs-collector.yaml @@ -0,0 +1,21 @@ +# Production forwarding template; configure a durable downstream outside this repo. +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 +processors: + memory_limiter: + check_interval: 1s + limit_mib: 64 + spike_limit_mib: 16 +exporters: + otlphttp: + endpoint: ${env:SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT} + headers: ${env:SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS} +service: + pipelines: + logs: + receivers: [otlp] + processors: [memory_limiter] + exporters: [otlphttp] diff --git a/compose.business-logs-collector.yml b/compose.business-logs-collector.yml new file mode 100644 index 000000000..1d2617642 --- /dev/null +++ b/compose.business-logs-collector.yml @@ -0,0 +1,20 @@ +# Optional production forwarder. No host port and no local file storage. +name: ${SKILLHUB_BUSINESS_LOGS_PROJECT:-skillhub-business-logs} +services: + business-logs-collector: + image: ${SKILLHUB_BUSINESS_LOGS_COLLECTOR_IMAGE:-otel/opentelemetry-collector-contrib:0.111.0} + command: ["--config=/etc/otelcol-contrib/config.yaml"] + restart: unless-stopped + mem_limit: 128m + cpus: 0.5 + environment: + SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT: ${SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT:?configure business-logs/.env downstream endpoint first} + SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS: ${SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS:-{}} + volumes: + - ./business-logs-collector.yaml:/etc/otelcol-contrib/config.yaml:ro + networks: + - business-logs +networks: + business-logs: + external: true + name: ${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs} diff --git a/compose.release.business-logs.yml b/compose.release.business-logs.yml new file mode 100644 index 000000000..2b46e5209 --- /dev/null +++ b/compose.release.business-logs.yml @@ -0,0 +1,10 @@ +# Optional attachment: retain the backend's ordinary dependency network. +services: + server: + networks: + - default + - business-logs +networks: + business-logs: + external: true + name: ${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs} diff --git a/scripts/runtime.sh b/scripts/runtime.sh index e2afdfad3..28e0c9f58 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -28,6 +28,8 @@ WEBSITE_ID="" ANALYTICS_UPSTREAM="" ANALYTICS_ADAPTER="umami" ANALYTICS_APP_ID="" +BUSINESS_LOGS_ENDPOINT="" +BUSINESS_LOGS_NETWORK="" while [ "$#" -gt 0 ]; do case "$1" in @@ -115,6 +117,16 @@ while [ "$#" -gt 0 ]; do ANALYTICS_APP_ID="$2" shift 2 ;; + --logs-endpoint) + [ "$#" -ge 2 ] || { echo "Missing value for --logs-endpoint" >&2; exit 1; } + BUSINESS_LOGS_ENDPOINT="$2" + shift 2 + ;; + --logs-network) + [ "$#" -ge 2 ] || { echo "Missing value for --logs-network" >&2; exit 1; } + BUSINESS_LOGS_NETWORK="$2" + shift 2 + ;; --help|-h) cat < umami (default) or datafinder (with --app-id) --app-id Existing DataFinder application ID +Business Logs (default off): + business-logs-up Prepare/start optional forwarder and enable server collection + First run creates business-logs/.env; configure downstream and retry + business-logs-enable Apply .env.release to server; --logs-endpoint overrides receiver + business-logs-disable Disable collection and recreate server; keep receiver and data + business-logs-down Stop only the optional Collector; keep files and network + business-logs-ps/logs Inspect only the optional Collector + --logs-endpoint Full OTLP/HTTP Logs URL (no credentials/query/fragment) + --logs-network Attach server to this external private network + Options: --version Use a specific image tag, for example v0.1.0 --aliyun Use the configured Aliyun mirror registry @@ -151,6 +173,13 @@ EOF esac done +if [ "$EXPLICIT_REF" = "false" ] && [ -n "$SKILLHUB_VERSION_VALUE" ]; then + case "$SKILLHUB_VERSION_VALUE" in + latest|edge) ;; # Floating image aliases are not Git refs; pin --ref explicitly. + *) SKILLHUB_REF="$SKILLHUB_VERSION_VALUE" ;; + esac +fi + if [ "$USE_ALIYUN" = "true" ]; then SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://imageless.oss-cn-beijing.aliyuncs.com}" RUNTIME_SCRIPT_URL="$SKILLHUB_RAW_BASE/runtime.sh" @@ -167,6 +196,10 @@ ANALYTICS_COMPOSE_FILE="$ANALYTICS_HOME/compose.analytics-umami.yml" ANALYTICS_ENV_FILE="$ANALYTICS_HOME/.env" ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example" ENV_FILE="$SKILLHUB_HOME/.env.release" +BUSINESS_LOGS_HOME="$SKILLHUB_HOME/business-logs" +BUSINESS_LOGS_ENV_FILE="$BUSINESS_LOGS_HOME/.env" +BUSINESS_LOGS_COMPOSE_FILE="$BUSINESS_LOGS_HOME/compose.business-logs-collector.yml" +BUSINESS_LOGS_OVERLAY_FILE="$SKILLHUB_HOME/compose.release.business-logs.yml" find_compose() { if docker compose version >/dev/null 2>&1; then @@ -362,6 +395,10 @@ prepare_runtime_files() { umask "$old_umask" fi secure_env_file + if [ "$COMMAND" = "business-logs-enable" ] || [ "$COMMAND" = "business-logs-up" ] || + [ "$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED false)" = "true" ]; then + download_file "$SKILLHUB_RAW_BASE/compose.release.business-logs.yml" "$BUSINESS_LOGS_OVERLAY_FILE" + fi if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}" @@ -425,10 +462,78 @@ run_compose() { # Preserve the normal default network and attach only Web to analytics. set -- -f "$OVERLAY_FILE" "$@" fi + if [ "$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED false)" = "true" ]; then + ensure_business_logs_network + set -- -f "$BUSINESS_LOGS_OVERLAY_FILE" "$@" + fi # shellcheck disable=SC2086 $compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" } +ensure_business_logs_network() { + SKILLHUB_BUSINESS_LOGS_NETWORK="$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs)" + export SKILLHUB_BUSINESS_LOGS_NETWORK + if ! docker network inspect "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null 2>&1; then + docker network create "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null + fi +} + +prepare_business_logs_files() { + mkdir -p "$BUSINESS_LOGS_HOME" + # Root-level names work unchanged on both versioned GitHub raw and flat OSS. + download_file "$SKILLHUB_RAW_BASE/compose.business-logs-collector.yml" "$BUSINESS_LOGS_COMPOSE_FILE" + download_file "$SKILLHUB_RAW_BASE/business-logs-collector.yaml" "$BUSINESS_LOGS_HOME/business-logs-collector.yaml" + if [ ! -f "$BUSINESS_LOGS_ENV_FILE" ]; then + old_umask="$(umask)" + umask 077 + { + printf 'SKILLHUB_BUSINESS_LOGS_PROJECT=%s\n' "$(get_env_value SKILLHUB_BUSINESS_LOGS_PROJECT skillhub-business-logs)" + printf 'SKILLHUB_BUSINESS_LOGS_NETWORK=%s\n' "$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs)" + printf 'SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=\n' + printf "SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{}'\n" + } >"$BUSINESS_LOGS_ENV_FILE" + umask "$old_umask" + fi + chmod 600 "$BUSINESS_LOGS_ENV_FILE" +} + +run_business_logs_compose() { + # The independent file owns its identity. Inherited Compose variables cannot redirect it. + SKILLHUB_BUSINESS_LOGS_PROJECT="$(get_env_value SKILLHUB_BUSINESS_LOGS_PROJECT skillhub-business-logs "$BUSINESS_LOGS_ENV_FILE")" + SKILLHUB_BUSINESS_LOGS_NETWORK="$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs "$BUSINESS_LOGS_ENV_FILE")" + export SKILLHUB_BUSINESS_LOGS_NETWORK + compose_cmd="$(find_compose)" + # shellcheck disable=SC2086 + $compose_cmd -p "$SKILLHUB_BUSINESS_LOGS_PROJECT" --env-file "$BUSINESS_LOGS_ENV_FILE" -f "$BUSINESS_LOGS_COMPOSE_FILE" "$@" +} + +validate_business_logs_endpoint() { + case "$1" in + http://*/*|https://*/*) ;; + *) echo "Business Logs requires a full HTTP(S) Logs URL" >&2; exit 1 ;; + esac + case "$1" in + *'@'*|*'?'*|*'#'*|*[[:space:]]*|*'\\'*) + echo "Business Logs URL must not contain credentials, query, fragment or whitespace" >&2; exit 1 ;; + esac +} + +enable_business_logs() { + receiver_endpoint="${BUSINESS_LOGS_ENDPOINT:-$(get_env_value SKILLHUB_BUSINESS_LOGS_ENDPOINT)}" + validate_business_logs_endpoint "$receiver_endpoint" + if [ -n "$BUSINESS_LOGS_NETWORK" ]; then + case "$BUSINESS_LOGS_NETWORK" in + *[!A-Za-z0-9_.-]*|[-.]* ) echo "Invalid Business Logs network name" >&2; exit 1 ;; + esac + set_env_value SKILLHUB_BUSINESS_LOGS_NETWORK "$BUSINESS_LOGS_NETWORK" + set_env_value SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED true + fi + set_env_value SKILLHUB_BUSINESS_LOGS_ENDPOINT "$receiver_endpoint" + set_env_value SKILLHUB_BUSINESS_LOGS_ENABLED true + run_compose up -d --no-deps --force-recreate server + echo "Business Logs enabled; produce a new event and query the receiver." +} + ensure_analytics_identity() { # Once initialized, the independent directory owns both identifiers. Neither # a recreated release file nor inherited Compose variables may redirect it. @@ -535,7 +640,7 @@ build_runtime_lifecycle_args() { if [ "$USE_ALIYUN" = "true" ]; then append_runtime_flag "--aliyun" - elif [ "$EXPLICIT_REF" = "true" ] || [ "$SKILLHUB_REF" != "main" ]; then + elif [ "$EXPLICIT_REF" = "true" ] || { [ "$SKILLHUB_REF" != "main" ] && [ "$SKILLHUB_REF" != "$SKILLHUB_VERSION_VALUE" ]; }; then append_runtime_option "--ref" "$SKILLHUB_REF" fi @@ -625,7 +730,11 @@ EOF ;; clean) run_compose down - if [ -d "$ANALYTICS_HOME" ]; then + if [ -d "$BUSINESS_LOGS_HOME" ] || [ -n "$(get_env_value SKILLHUB_BUSINESS_LOGS_ENDPOINT)" ]; then + # Retain operator credentials, Collector config and identities for safe restart. + rm -f "$COMPOSE_FILE" "$BUSINESS_LOGS_OVERLAY_FILE" "$ENV_EXAMPLE_FILE" + echo "Downloaded release files removed; operator environment and business-logs files retained." + elif [ -d "$ANALYTICS_HOME" ]; then # Keep independent platform credentials and files; never strand its database. rm -f "$COMPOSE_FILE" "$OVERLAY_FILE" "$ENV_EXAMPLE_FILE" "$ENV_FILE" echo "SkillHub files removed; independent analytics files retained in $ANALYTICS_HOME" @@ -642,6 +751,40 @@ EOF pull) run_compose pull ;; + business-logs-up) + prepare_business_logs_files + downstream_endpoint="$(get_env_value SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT "" "$BUSINESS_LOGS_ENV_FILE")" + if [ -z "$downstream_endpoint" ]; then + echo "Configure SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT (OTLP base URL) and optional JSON headers in $BUSINESS_LOGS_ENV_FILE, then retry." >&2 + exit 1 + fi + validate_business_logs_endpoint "${downstream_endpoint%/}/v1/logs" + # Read the independent project's stored network; do not overwrite operator secrets. + BUSINESS_LOGS_NETWORK="$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs "$BUSINESS_LOGS_ENV_FILE")" + set_env_value SKILLHUB_BUSINESS_LOGS_NETWORK "$BUSINESS_LOGS_NETWORK" + ensure_business_logs_network + run_business_logs_compose config --quiet + run_business_logs_compose up -d --force-recreate + BUSINESS_LOGS_ENDPOINT=http://business-logs-collector:4318/v1/logs + enable_business_logs + ;; + business-logs-enable) + enable_business_logs + ;; + business-logs-disable) + set_env_value SKILLHUB_BUSINESS_LOGS_ENABLED false + set_env_value SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED false + run_compose up -d --no-deps --force-recreate server + echo "Business Logs disabled; receiver data and network retained." + ;; + business-logs-down|business-logs-ps|business-logs-logs) + [ -f "$BUSINESS_LOGS_ENV_FILE" ] || { echo "No optional Collector; run business-logs-up first" >&2; exit 1; } + case "$COMMAND" in + business-logs-down) run_business_logs_compose stop ;; + business-logs-ps) run_business_logs_compose ps ;; + business-logs-logs) run_business_logs_compose logs -f ;; + esac + ;; analytics-up) prepare_analytics_files ensure_analytics_network From b1551c2e698e8f74c783993d6d894c2748cc65fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 15:52:04 +0800 Subject: [PATCH 11/21] feat: add network-aware local business logs demo targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- Makefile | 19 ++++- .../examples/business-event-logs/compose.yaml | 14 +++- scripts/business-logs-demo.sh | 74 +++++++++++++++++++ 3 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 scripts/business-logs-demo.sh diff --git a/Makefile b/Makefile index 2fa6214cc..dbb7eba57 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ .PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-github-sync dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke suite-smoke suite-bundle-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci +.DEFAULT_GOAL := help DEV_DIR := .dev DEV_SERVER_PID := $(DEV_DIR)/server.pid DEV_WEB_PID := $(DEV_DIR)/web.pid @@ -14,7 +15,7 @@ STAGING_WEB_URL := http://localhost STAGING_SERVER_IMAGE := skillhub-server:staging DEV_PROCESS := bash scripts/dev-process.sh DEV_SERVER_PREPARE := true -DEV_SERVER_CMD := ./scripts/run-dev-app.sh +DEV_SERVER_CMD := bash ../scripts/business-logs-demo.sh run-host ./scripts/run-dev-app.sh DEV_SERVER_SCANNER_ENV := SKILLHUB_SECURITY_SCANNER_ENABLED=true SKILLHUB_SECURITY_SCANNER_URL=$(DEV_SCANNER_URL) SKILLHUB_SECURITY_SCANNER_MODE=upload BACKEND_TEST_JAVA_OPTIONS ?= -XX:+EnableDynamicAgentLoading PARALLEL_BASE_REF ?= origin/main @@ -25,6 +26,22 @@ DEV_COMPOSE := docker compose -p $(DEV_COMPOSE_PROJECT_NAME) STAGING_BASE_COMPOSE := docker compose -p $(STAGING_COMPOSE_PROJECT_NAME) STAGING_COMPOSE := $(STAGING_BASE_COMPOSE) -f docker-compose.yml -f docker-compose.staging.yml +.PHONY: business-logs-demo-up business-logs-demo-status business-logs-demo-records business-logs-demo-verify business-logs-demo-down +business-logs-demo-up: ## 启动本地业务日志 Collector(BUSINESS_LOGS_BACKEND=host 或 container) + sh scripts/business-logs-demo.sh up + +business-logs-demo-status: ## 查看本地业务日志 Collector + sh scripts/business-logs-demo.sh status + +business-logs-demo-records: ## 输出 Collector 实收 JSON 行 + sh scripts/business-logs-demo.sh records + +business-logs-demo-verify: ## 验证实收事件(默认 skill.download.prepared) + sh scripts/business-logs-demo.sh verify + +business-logs-demo-down: ## 停止本地 Collector,保留记录与网络 + sh scripts/business-logs-demo.sh down + help: ## 显示帮助 @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' diff --git a/docs/examples/business-event-logs/compose.yaml b/docs/examples/business-event-logs/compose.yaml index e7428c8a5..b3a073336 100644 --- a/docs/examples/business-event-logs/compose.yaml +++ b/docs/examples/business-event-logs/compose.yaml @@ -1,14 +1,20 @@ -name: skillhub-1009-business-logs +# Local trial only. JSON in the container is not production storage. +name: ${SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT:-skillhub-business-logs-demo} services: - collector: + business-logs-collector: image: otel/opentelemetry-collector-contrib:0.111.0 - container_name: skillhub-1009-collector command: ["--config=/etc/otelcol-contrib/config.yaml"] user: "0:0" mem_limit: 128m cpus: 0.5 ports: - - "127.0.0.1:4318:4318" + - "127.0.0.1:${SKILLHUB_BUSINESS_LOGS_DEMO_PORT:-4318}:4318" volumes: - ./collector.yaml:/etc/otelcol-contrib/config.yaml:ro + networks: + - business-logs +networks: + business-logs: + external: true + name: ${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs-demo} diff --git a/scripts/business-logs-demo.sh b/scripts/business-logs-demo.sh new file mode 100644 index 000000000..6959f985a --- /dev/null +++ b/scripts/business-logs-demo.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Source-tree trial only; reuse the release network overlay and pinned demo config. +set -eu +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +STATE_DIR=${BUSINESS_LOGS_DEMO_STATE_DIR:-$ROOT/.dev/business-logs} +COMMAND=${1:-status} +shift || true + +if [ "$COMMAND" = run-host ]; then + if [ -f "$STATE_DIR/host-port" ]; then + SKILLHUB_BUSINESS_LOGS_ENABLED=true + SKILLHUB_BUSINESS_LOGS_ENDPOINT="http://127.0.0.1:$(cat "$STATE_DIR/host-port")/v1/logs" + export SKILLHUB_BUSINESS_LOGS_ENABLED SKILLHUB_BUSINESS_LOGS_ENDPOINT + fi + exec "$@" +fi + +export SKILLHUB_BUSINESS_LOGS_NETWORK=${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs-demo} +export SKILLHUB_BUSINESS_LOGS_DEMO_PORT=${SKILLHUB_BUSINESS_LOGS_DEMO_PORT:-4318} +export SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT=${SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT:-skillhub-business-logs-demo} +BACKEND=${BUSINESS_LOGS_BACKEND:-host} +compose() { + docker compose -p "$SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT" -f "$ROOT/docs/examples/business-event-logs/compose.yaml" "$@" +} +backend_compose() { + : "${BUSINESS_LOGS_BACKEND_ENV_FILE:?provide the backend release env file}" + : "${BUSINESS_LOGS_BACKEND_PROJECT:?provide the backend Compose project name}" + docker compose -p "$BUSINESS_LOGS_BACKEND_PROJECT" --env-file "$BUSINESS_LOGS_BACKEND_ENV_FILE" \ + -f "${BUSINESS_LOGS_BACKEND_COMPOSE:-$ROOT/compose.release.yml}" \ + -f "$ROOT/compose.release.business-logs.yml" "$@" +} +case "$COMMAND" in + up) + case "$BACKEND" in host|container) ;; *) echo 'BUSINESS_LOGS_BACKEND must be host or container' >&2; exit 1 ;; esac + docker network inspect "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null 2>&1 || docker network create "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null + compose up -d + if [ "$BACKEND" = host ]; then + mkdir -p "$STATE_DIR" + printf '%s\n' "$SKILLHUB_BUSINESS_LOGS_DEMO_PORT" >"$STATE_DIR/host-port" + echo 'Collector ready. Start/restart the host backend with make dev-server.' + else + SKILLHUB_BUSINESS_LOGS_ENABLED=true + SKILLHUB_BUSINESS_LOGS_ENDPOINT=http://business-logs-collector:4318/v1/logs + export SKILLHUB_BUSINESS_LOGS_ENABLED SKILLHUB_BUSINESS_LOGS_ENDPOINT + backend_compose up -d --no-deps --force-recreate server + fi + ;; + status) compose ps ;; + records|verify) + command -v jq >/dev/null || { echo 'Install jq to query demo records' >&2; exit 1; } + scratch=$(mktemp -d) + trap 'rm -rf "$scratch"' EXIT HUP INT TERM + compose cp business-logs-collector:/business-logs.json "$scratch/records.jsonl" >/dev/null + if [ "$COMMAND" = records ]; then + cat "$scratch/records.jsonl" + else + jq -e -s --arg event "${BUSINESS_LOGS_EXPECT_EVENT:-skill.download.prepared}" \ + 'any(.[].resourceLogs[].scopeLogs[].logRecords[].attributes[]; .key == "skillhub.event.name" and .value.stringValue == $event)' \ + "$scratch/records.jsonl" >/dev/null + printf 'Verified event.name=%s in actual Collector records\n' "${BUSINESS_LOGS_EXPECT_EVENT:-skill.download.prepared}" + fi + ;; + down) + rm -f "$STATE_DIR/host-port" + if [ "$BACKEND" = container ]; then + SKILLHUB_BUSINESS_LOGS_ENABLED=false + export SKILLHUB_BUSINESS_LOGS_ENABLED + backend_compose up -d --no-deps --force-recreate server + fi + compose stop + echo 'Collector stopped; container records and external network retained. Restart a running host backend to disable collection.' + ;; + *) echo 'Usage: business-logs-demo.sh up|status|records|verify|down|run-host command...' >&2; exit 1 ;; +esac From 27779a3d0503326e1d5f0e50e289a874b9527fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 15:52:04 +0800 Subject: [PATCH 12/21] test: cover business logs deployment lifecycles and register CI checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- .github/workflows/pr-scripts.yml | 10 +++ scripts/tests/runtime-business-logs-test.sh | 69 +++++++++++++++++++++ scripts/tests/test_business_logs_config.py | 22 +++++++ 3 files changed, 101 insertions(+) create mode 100644 scripts/tests/runtime-business-logs-test.sh diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index a813412b6..d668dc511 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -11,6 +11,10 @@ on: - 'compose.release.yml' - 'compose.release.analytics.yml' - 'compose.analytics-umami.yml' + - 'compose.business-logs-collector.yml' + - 'compose.release.business-logs.yml' + - 'business-logs-collector.yaml' + - 'docs/examples/business-event-logs/**' - 'compose.storage.*.yml' - 'deploy/storage/**' - 'deploy/runtime-mirror-images.txt' @@ -58,6 +62,12 @@ jobs: - run: bash scripts/tests/publish-cli-test.sh - run: bash scripts/tests/runtime-secret-test.sh - run: bash scripts/tests/runtime-analytics-test.sh + - run: bash scripts/tests/runtime-business-logs-test.sh + - uses: azure/setup-helm@v4 + with: + version: v3.19.0 + - run: helm dependency build charts/skillhub + - run: python3 scripts/tests/test_business_logs_config.py - run: bash scripts/tests/validate-release-config-test.sh - run: bash scripts/tests/storage-compose-test.sh - run: bash scripts/tests/storage-compose-render-test.sh diff --git a/scripts/tests/runtime-business-logs-test.sh b/scripts/tests/runtime-business-logs-test.sh new file mode 100644 index 000000000..70a2d9a76 --- /dev/null +++ b/scripts/tests/runtime-business-logs-test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" "$TMP/home" +cat >"$TMP/bin/docker" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$DOCKER_LOG" +case "$*" in + 'network inspect '*) [ -f "$NETWORK_MARKER" ] ;; + 'network create '*) touch "$NETWORK_MARKER" ;; + *' cp business-logs-collector:'*) + for last do :; done + printf '%s\n' '{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"attributes":[{"key":"skillhub.event.name","value":{"stringValue":"skill.download.prepared"}}]}]}]}]}' >"$last" + ;; +esac +SH +chmod +x "$TMP/bin/docker" +export DOCKER_LOG="$TMP/docker.log" NETWORK_MARKER="$TMP/network" SKILLHUB_HOME="$TMP/home" SKILLHUB_RAW_BASE="file://$ROOT" PATH="$TMP/bin:$PATH" +run() { sh "$ROOT/scripts/runtime.sh" "$@" >"$TMP/output" 2>&1; } +fail() { echo "FAIL: $*" >&2; exit 1; } +mode() { if stat -c %a "$1" >/dev/null 2>&1; then stat -c %a "$1"; else stat -f %Lp "$1"; fi; } +run up --no-scanner +! grep -q 'compose.business-logs\|network create' "$DOCKER_LOG" || fail 'default started collection' +grep -q '^SKILLHUB_BUSINESS_LOGS_ENABLED=false$' "$TMP/home/.env.release" || fail 'not default off' +if run business-logs-enable --logs-endpoint 'https://fake:secret@receiver.invalid/v1/logs'; then fail 'credential URL accepted'; fi +! grep -q 'fake:secret' "$TMP/output" || fail 'URL leaked' +run business-logs-enable --logs-endpoint https://receiver.example.com/v1/logs +grep -q '^SKILLHUB_BUSINESS_LOGS_ENABLED=true$' "$TMP/home/.env.release" || fail 'existing receiver not enabled' +grep -q '^SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED=false$' "$TMP/home/.env.release" || fail 'public receiver joined network' +run business-logs-enable --logs-network synthetic-business-logs --logs-endpoint http://existing:4318/v1/logs +run up --no-scanner +grep -q 'compose.release.business-logs.yml up -d postgres' "$DOCKER_LOG" || fail 'plain up lost network overlay' +[ "$(grep -c '^network create ' "$DOCKER_LOG")" = 1 ] || fail 'network not idempotent' +if run business-logs-up; then fail 'missing downstream accepted'; fi +[ "$(mode "$TMP/home/business-logs/.env")" = 600 ] || fail 'Collector env permissions' +cat >>"$TMP/home/business-logs/.env" <<'ENV' +SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://sink.example.com +SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{"Authorization":"Bearer SYNTHETIC-NOT-A-SECRET"}' +ENV +before=$(shasum -a 256 "$TMP/home/business-logs/.env") +run business-logs-up +run business-logs-up +[ "$(shasum -a 256 "$TMP/home/business-logs/.env")" = "$before" ] || fail 'Collector secrets overwritten' +! grep -q SYNTHETIC-NOT-A-SECRET "$TMP/output" "$DOCKER_LOG" || fail 'header leaked' +grep -q 'compose.business-logs-collector.yml up -d --force-recreate' "$DOCKER_LOG" || fail 'Collector update not applied' +grep -q '^SKILLHUB_BUSINESS_LOGS_ENDPOINT=http://business-logs-collector:4318/v1/logs$' "$TMP/home/.env.release" || fail 'wrong internal endpoint' +run business-logs-down +run business-logs-ps +run business-logs-up +grep -q 'compose.business-logs-collector.yml stop' "$DOCKER_LOG" || fail 'stop missing' +! grep -q 'compose.business-logs-collector.yml down' "$DOCKER_LOG" || fail 'stop deletes project' +run business-logs-disable +grep -q '^SKILLHUB_BUSINESS_LOGS_ENABLED=false$' "$TMP/home/.env.release" || fail 'disable failed' +run clean +[ -f "$TMP/home/.env.release" ] && [ -f "$TMP/home/business-logs/.env" ] || fail 'clean removed operator secrets' +run business-logs-up --aliyun +[ -f "$TMP/home/business-logs/business-logs-collector.yaml" ] || fail 'flat OSS retrieval failed' + +export BUSINESS_LOGS_DEMO_STATE_DIR="$TMP/state" SKILLHUB_BUSINESS_LOGS_DEMO_PORT=14318 +sh "$ROOT/scripts/business-logs-demo.sh" up >"$TMP/demo-output" +result=$(sh "$ROOT/scripts/business-logs-demo.sh" run-host sh -c 'printf "%s %s" "$SKILLHUB_BUSINESS_LOGS_ENABLED" "$SKILLHUB_BUSINESS_LOGS_ENDPOINT"') +[ "$result" = 'true http://127.0.0.1:14318/v1/logs' ] || fail 'host receive address' +export BUSINESS_LOGS_BACKEND=container BUSINESS_LOGS_BACKEND_PROJECT=synthetic-backend BUSINESS_LOGS_BACKEND_ENV_FILE="$TMP/home/.env.release" +make -C "$ROOT" business-logs-demo-up business-logs-demo-status business-logs-demo-records business-logs-demo-verify business-logs-demo-down >"$TMP/demo-output" +grep -q 'synthetic-backend .*compose.release.business-logs.yml up -d --no-deps --force-recreate server' "$DOCKER_LOG" || fail 'container network overlay missing' +[ ! -f "$TMP/state/host-port" ] || fail 'host state not disabled' +echo 'runtime-business-logs-test passed (fake-Docker lifecycle and demo contracts)' diff --git a/scripts/tests/test_business_logs_config.py b/scripts/tests/test_business_logs_config.py index 2b73c4a08..2b344205c 100644 --- a/scripts/tests/test_business_logs_config.py +++ b/scripts/tests/test_business_logs_config.py @@ -46,6 +46,10 @@ class BusinessLogsDeploymentTest(unittest.TestCase): self.assertIn("SKILLHUB_BUSINESS_LOGS_ENDPOINT is required", result.stderr) self.assertEqual(self.validate("SKILLHUB_BUSINESS_LOGS_ENABLED=true\nSKILLHUB_BUSINESS_LOGS_ENDPOINT=http://collector:4318/v1/logs\n").returncode, 0) + def test_metrics_opt_in_and_wildcard_rejection(self): + self.assertEqual(self.validate("SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus\n").returncode, 0) + self.assertNotEqual(self.validate("SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=*\n").returncode, 0) + def test_release_rejects_secret_bearing_url_without_printing_it(self): result = self.validate("SKILLHUB_BUSINESS_LOGS_ENABLED=true\nSKILLHUB_BUSINESS_LOGS_ENDPOINT=https://user:fake-secret@receiver.invalid/v1/logs?token=fake-query\n") self.assertNotEqual(result.returncode, 0) @@ -57,6 +61,7 @@ class BusinessLogsDeploymentTest(unittest.TestCase): result = self.run_command(*command) self.assertEqual(result.returncode, 0, result.stderr) environment = json.loads(result.stdout)["services"]["server"]["environment"] + self.assertEqual(environment["SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE"], "health,info") self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENABLED"], "false") self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_ENDPOINT"], "") self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_MAX_QUEUE_SIZE"], "2048") @@ -69,6 +74,23 @@ class BusinessLogsDeploymentTest(unittest.TestCase): self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST"], "7,12") self.assertEqual(environment["SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST"], "") + def test_collector_downstream_and_private_demo_network_contracts(self): + self.fixture.write_text(BASE + "SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://receiver.example.com\nSKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{}'\n") + command = ("docker", "compose", "--env-file", str(self.fixture), "-p", "skillhub-business-contract") + result = self.run_command(*command, "-f", "compose.business-logs-collector.yml", "config", "--format", "json") + self.assertEqual(result.returncode, 0, result.stderr) + production = json.loads(result.stdout) + service = production["services"]["business-logs-collector"] + self.assertFalse(service.get("ports")) + self.assertEqual(service["environment"]["SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS"], "{}") + self.assertEqual(service["mem_limit"], "134217728") + self.assertTrue(production["networks"]["business-logs"]["external"]) + result = self.run_command(*command, "-f", "docs/examples/business-event-logs/compose.yaml", "config", "--format", "json") + self.assertEqual(result.returncode, 0, result.stderr) + demo = json.loads(result.stdout)["services"]["business-logs-collector"] + self.assertEqual(demo["ports"][0]["host_ip"], "127.0.0.1") + self.assertNotIn("container_name", demo) + @unittest.skipUnless(shutil.which("helm"), "helm executable required") def test_helm_defaults_secret_reference_and_invalid_enabled_config(self): command = ("helm", "template", "skillhub-1009-config", "charts/skillhub", "-f", "charts/skillhub/tests/test-values.yaml", "--show-only", "templates/server-deployment.yaml") From a9047192256e87a21417edeb75976d35d25db286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 16:14:31 +0800 Subject: [PATCH 13/21] fix: report external receiver status and harden demo shell entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- scripts/business-logs-demo.sh | 2 +- scripts/runtime.sh | 16 ++++++++++++---- scripts/tests/runtime-business-logs-test.sh | 5 +++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/business-logs-demo.sh b/scripts/business-logs-demo.sh index 6959f985a..67ac783ae 100644 --- a/scripts/business-logs-demo.sh +++ b/scripts/business-logs-demo.sh @@ -4,7 +4,7 @@ set -eu ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) STATE_DIR=${BUSINESS_LOGS_DEMO_STATE_DIR:-$ROOT/.dev/business-logs} COMMAND=${1:-status} -shift || true +[ "$#" -eq 0 ] || shift if [ "$COMMAND" = run-host ]; then if [ -f "$STATE_DIR/host-port" ]; then diff --git a/scripts/runtime.sh b/scripts/runtime.sh index 28e0c9f58..9d7493dc0 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -146,7 +146,8 @@ Business Logs (default off): business-logs-enable Apply .env.release to server; --logs-endpoint overrides receiver business-logs-disable Disable collection and recreate server; keep receiver and data business-logs-down Stop only the optional Collector; keep files and network - business-logs-ps/logs Inspect only the optional Collector + business-logs-ps Show collection state and optional Collector status + business-logs-logs Follow optional Collector logs --logs-endpoint Full OTLP/HTTP Logs URL (no credentials/query/fragment) --logs-network Attach server to this external private network @@ -513,7 +514,7 @@ validate_business_logs_endpoint() { *) echo "Business Logs requires a full HTTP(S) Logs URL" >&2; exit 1 ;; esac case "$1" in - *'@'*|*'?'*|*'#'*|*[[:space:]]*|*'\\'*) + *'@'*|*'?'*|*'#'*|*[[:space:]]*|*'\'*) echo "Business Logs URL must not contain credentials, query, fragment or whitespace" >&2; exit 1 ;; esac } @@ -777,11 +778,18 @@ EOF run_compose up -d --no-deps --force-recreate server echo "Business Logs disabled; receiver data and network retained." ;; - business-logs-down|business-logs-ps|business-logs-logs) + business-logs-ps) + printf 'Business Logs enabled: %s\n' "$(get_env_value SKILLHUB_BUSINESS_LOGS_ENABLED false)" + if [ -f "$BUSINESS_LOGS_ENV_FILE" ]; then + run_business_logs_compose ps + else + echo "No optional Collector; the receiver is managed externally." + fi + ;; + business-logs-down|business-logs-logs) [ -f "$BUSINESS_LOGS_ENV_FILE" ] || { echo "No optional Collector; run business-logs-up first" >&2; exit 1; } case "$COMMAND" in business-logs-down) run_business_logs_compose stop ;; - business-logs-ps) run_business_logs_compose ps ;; business-logs-logs) run_business_logs_compose logs -f ;; esac ;; diff --git a/scripts/tests/runtime-business-logs-test.sh b/scripts/tests/runtime-business-logs-test.sh index 70a2d9a76..614a7a45a 100644 --- a/scripts/tests/runtime-business-logs-test.sh +++ b/scripts/tests/runtime-business-logs-test.sh @@ -26,9 +26,13 @@ run up --no-scanner grep -q '^SKILLHUB_BUSINESS_LOGS_ENABLED=false$' "$TMP/home/.env.release" || fail 'not default off' if run business-logs-enable --logs-endpoint 'https://fake:secret@receiver.invalid/v1/logs'; then fail 'credential URL accepted'; fi ! grep -q 'fake:secret' "$TMP/output" || fail 'URL leaked' +if run business-logs-enable --logs-endpoint 'http://receiver\invalid/v1/logs'; then fail 'backslash URL accepted'; fi run business-logs-enable --logs-endpoint https://receiver.example.com/v1/logs grep -q '^SKILLHUB_BUSINESS_LOGS_ENABLED=true$' "$TMP/home/.env.release" || fail 'existing receiver not enabled' grep -q '^SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED=false$' "$TMP/home/.env.release" || fail 'public receiver joined network' +run business-logs-ps +grep -q 'Business Logs enabled: true' "$TMP/output" || fail 'existing receiver status missing' +grep -q 'managed externally' "$TMP/output" || fail 'existing receiver status requires Collector' run business-logs-enable --logs-network synthetic-business-logs --logs-endpoint http://existing:4318/v1/logs run up --no-scanner grep -q 'compose.release.business-logs.yml up -d postgres' "$DOCKER_LOG" || fail 'plain up lost network overlay' @@ -59,6 +63,7 @@ run business-logs-up --aliyun [ -f "$TMP/home/business-logs/business-logs-collector.yaml" ] || fail 'flat OSS retrieval failed' export BUSINESS_LOGS_DEMO_STATE_DIR="$TMP/state" SKILLHUB_BUSINESS_LOGS_DEMO_PORT=14318 +sh "$ROOT/scripts/business-logs-demo.sh" >"$TMP/demo-output" sh "$ROOT/scripts/business-logs-demo.sh" up >"$TMP/demo-output" result=$(sh "$ROOT/scripts/business-logs-demo.sh" run-host sh -c 'printf "%s %s" "$SKILLHUB_BUSINESS_LOGS_ENABLED" "$SKILLHUB_BUSINESS_LOGS_ENDPOINT"') [ "$result" = 'true http://127.0.0.1:14318/v1/logs' ] || fail 'host receive address' From 5f8523160baf31e9cfe5cd5646b82aad0b0579d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 16:44:50 +0800 Subject: [PATCH 14/21] test: bound the isolated diagnostics execution to its own class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- server/skillhub-app/pom.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 9445615c9..224e42d11 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -183,6 +183,10 @@ test process-global-diagnostics + + **/BusinessLogsDiagnosticsTest.java + + false 1 false From d1fa71c9a94de77d51b5b03aa166193d8623a516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 16:47:34 +0800 Subject: [PATCH 15/21] fix: preserve demo records across Collector restarts and config reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- .../business-event-logs/collector.yaml | 3 +- .../examples/business-event-logs/compose.yaml | 3 ++ scripts/business-logs-demo.sh | 6 ++-- scripts/tests/runtime-business-logs-test.sh | 2 +- scripts/tests/test_business_logs_config.py | 2 ++ .../business/BusinessLogsCollectorTest.java | 29 ++++++++++++++++++- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/examples/business-event-logs/collector.yaml b/docs/examples/business-event-logs/collector.yaml index 1ce8d3783..b453a2eea 100644 --- a/docs/examples/business-event-logs/collector.yaml +++ b/docs/examples/business-event-logs/collector.yaml @@ -10,7 +10,8 @@ processors: spike_limit_mib: 16 exporters: file: - path: /business-logs.json + path: /data/business-logs.json + append: true flush_interval: 100ms service: pipelines: diff --git a/docs/examples/business-event-logs/compose.yaml b/docs/examples/business-event-logs/compose.yaml index b3a073336..5e3be31b6 100644 --- a/docs/examples/business-event-logs/compose.yaml +++ b/docs/examples/business-event-logs/compose.yaml @@ -11,8 +11,11 @@ services: - "127.0.0.1:${SKILLHUB_BUSINESS_LOGS_DEMO_PORT:-4318}:4318" volumes: - ./collector.yaml:/etc/otelcol-contrib/config.yaml:ro + - business_logs_demo_data:/data networks: - business-logs +volumes: + business_logs_demo_data: networks: business-logs: external: true diff --git a/scripts/business-logs-demo.sh b/scripts/business-logs-demo.sh index 67ac783ae..29d7e59f7 100644 --- a/scripts/business-logs-demo.sh +++ b/scripts/business-logs-demo.sh @@ -33,7 +33,7 @@ case "$COMMAND" in up) case "$BACKEND" in host|container) ;; *) echo 'BUSINESS_LOGS_BACKEND must be host or container' >&2; exit 1 ;; esac docker network inspect "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null 2>&1 || docker network create "$SKILLHUB_BUSINESS_LOGS_NETWORK" >/dev/null - compose up -d + compose up -d --force-recreate if [ "$BACKEND" = host ]; then mkdir -p "$STATE_DIR" printf '%s\n' "$SKILLHUB_BUSINESS_LOGS_DEMO_PORT" >"$STATE_DIR/host-port" @@ -48,9 +48,9 @@ case "$COMMAND" in status) compose ps ;; records|verify) command -v jq >/dev/null || { echo 'Install jq to query demo records' >&2; exit 1; } - scratch=$(mktemp -d) + scratch=$(mktemp -d "${TMPDIR:-/tmp}/skillhub-business-logs.XXXXXX") trap 'rm -rf "$scratch"' EXIT HUP INT TERM - compose cp business-logs-collector:/business-logs.json "$scratch/records.jsonl" >/dev/null + compose cp business-logs-collector:/data/business-logs.json "$scratch/records.jsonl" >/dev/null if [ "$COMMAND" = records ]; then cat "$scratch/records.jsonl" else diff --git a/scripts/tests/runtime-business-logs-test.sh b/scripts/tests/runtime-business-logs-test.sh index 614a7a45a..e70271e3a 100644 --- a/scripts/tests/runtime-business-logs-test.sh +++ b/scripts/tests/runtime-business-logs-test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail ROOT=$(cd "$(dirname "$0")/../.." && pwd) -TMP=$(mktemp -d) +TMP=$(mktemp -d "${TMPDIR:-/tmp}/skillhub-business-logs-test.XXXXXX") trap 'rm -rf "$TMP"' EXIT mkdir -p "$TMP/bin" "$TMP/home" cat >"$TMP/bin/docker" <<'SH' diff --git a/scripts/tests/test_business_logs_config.py b/scripts/tests/test_business_logs_config.py index 2b344205c..cdd799b36 100644 --- a/scripts/tests/test_business_logs_config.py +++ b/scripts/tests/test_business_logs_config.py @@ -90,6 +90,8 @@ class BusinessLogsDeploymentTest(unittest.TestCase): demo = json.loads(result.stdout)["services"]["business-logs-collector"] self.assertEqual(demo["ports"][0]["host_ip"], "127.0.0.1") self.assertNotIn("container_name", demo) + self.assertTrue(any(volume["target"] == "/data" and volume["type"] == "volume" for volume in demo["volumes"])) + self.assertIn("append: true", (ROOT / "docs/examples/business-event-logs/collector.yaml").read_text()) @unittest.skipUnless(shutil.which("helm"), "helm executable required") def test_helm_defaults_secret_reference_and_invalid_enabled_config(self): diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java index d17bdfeec..dcf5f14e2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java @@ -41,6 +41,7 @@ class BusinessLogsCollectorTest { .withCreateContainerCmdModifier(cmd -> { cmd.withName("skillhub-1009-collector-integration"); cmd.withUser("0:0"); + cmd.withVolumes(new com.github.dockerjava.api.model.Volume("/data")); cmd.getHostConfig().withMemory(128L * 1024 * 1024).withNanoCPUs(500_000_000L); }) .waitingFor(Wait.forLogMessage(".*Everything is ready.*", 1)); @@ -105,6 +106,32 @@ class BusinessLogsCollectorTest { if (evidence != null) { Files.writeString(Path.of(evidence, "collector-received.jsonl"), raw); } System.out.println("BUSINESS_LOGS real Collector 0.111.0: queried 27 records / 9 event names / 27 unique uids; modes none,otel-sdk(sample=0),external-agent; 128 MiB, 0.5 CPU"); String endpoint = "http://" + COLLECTOR.getHost() + ":" + COLLECTOR.getMappedPort(4318) + "/v1/logs"; + // A real stop/start must not truncate the local trial's records. Wait for + // the restarted HTTP pipeline before reading, rather than racing file open. + int previousPort = COLLECTOR.getMappedPort(4318); + COLLECTOR.getDockerClient().stopContainerCmd(COLLECTOR.getContainerId()).exec(); + COLLECTOR.getDockerClient().startContainerCmd(COLLECTOR.getContainerId()).exec(); + // Docker may allocate another dynamic host port on restart. Inspect the + // actual binding rather than using GenericContainer's cached initial info. + String restartedPort = COLLECTOR.getDockerClient().inspectContainerCmd(COLLECTOR.getContainerId()).exec() + .getNetworkSettings().getPorts().getBindings() + .get(com.github.dockerjava.api.model.ExposedPort.tcp(4318))[0].getHostPortSpec(); + endpoint = "http://" + COLLECTOR.getHost() + ":" + restartedPort + "/v1/logs"; + System.out.printf("BUSINESS_LOGS Collector restart mapped port: %d -> %s%n", previousPort, restartedPort); + var client = java.net.http.HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)).build(); + var probe = java.net.http.HttpRequest.newBuilder(java.net.URI.create(endpoint)) + .timeout(Duration.ofSeconds(1)).header("Content-Type", "application/x-protobuf") + .POST(java.net.http.HttpRequest.BodyPublishers.noBody()).build(); + try { + org.awaitility.Awaitility.await().atMost(Duration.ofSeconds(10)).ignoreExceptions().untilAsserted(() -> + assertThat(client.send(probe, java.net.http.HttpResponse.BodyHandlers.discarding()).statusCode()).isEqualTo(200)); + } finally { + client.close(); + } + assertThat(records(awaitFile(27)).stream() + .map(record -> attributes(record).get("log.record.uid").asText()) + .collect(java.util.stream.Collectors.toSet())).isEqualTo(uids); + System.out.println("BUSINESS_LOGS real Collector stop/start: all 27 original record UIDs retained"); COLLECTOR.getDockerClient().pauseContainerCmd(COLLECTOR.getContainerId()).exec(); try { outageRunner(endpoint).run(context -> { @@ -151,7 +178,7 @@ class BusinessLogsCollectorTest { Exception lastError = null; do { try { - raw = COLLECTOR.copyFileFromContainer("/business-logs.json", stream -> new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + raw = COLLECTOR.copyFileFromContainer("/data/business-logs.json", stream -> new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); if (records(raw).size() >= expected) { return raw; } } catch (Exception ex) { lastError = ex; } TimeUnit.MILLISECONDS.sleep(100); From f0dde1a780436c41b38f34ce72e49bbc04a79db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 16:47:35 +0800 Subject: [PATCH 16/21] docs: separate production business logs integration from local trial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- charts/skillhub/README.md | 4 +- docs/09-deployment.md | 4 +- docs/business-event-logs.md | 174 ++++++++++++++++++++++----- docs/skillhub/en/guide/deployment.md | 14 +-- docs/skillhub/guide/deployment.md | 11 +- 5 files changed, 159 insertions(+), 48 deletions(-) diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 02c81852a..bcbe10c9c 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -10,7 +10,9 @@ - **安全**:TLS 证书管理、Secret 密码保护;Bitnami 数据组件默认提供 NetworkPolicy - **可观测性**:内置 Prometheus metrics exporter - **业务事件 Logs(默认关闭)**:`server.businessLogs.enabled`、`endpoint` 与 `headersSecretRef`(引用现有 Secret); - 其他预算/过滤配置使用 `server.extraEnv`,不增加默认 Collector。见[完整运行手册](../../docs/business-event-logs.md)。 + 其他预算/过滤配置使用 `server.extraEnv`,复用现有接收器,不增加 Collector 子 Chart。 + 指标默认只暴露 health/info;`server.actuatorExposureInclude: health,info,metrics,prometheus` 可打开受保护的查询入口。 + Secret 创建、upgrade、产生事件、管理员查询与停用见[完整运行手册](../../docs/business-event-logs.md)。 ## 快速开始 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index c15034ae1..d62c0da3e 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -667,8 +667,8 @@ override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 ` | 请求关联 | 响应头和日志中的 `X-Request-Id` / `request.id` | | 日志 | 文本或 ECS 风格 JSON,均输出到容器 stdout / stderr | | Trace | `none`、Micrometer + OTel SDK、或外部 Java Agent 三选一 | -| 业务事实 | 默认关闭的独立 OTLP Logs 出口;[事件目录、配置、Secret、Collector 实收查询与可靠性边界](./business-event-logs.md) | -| 指标 | Spring Boot Actuator;Prometheus 是可选后端,不是 Trace 前置条件 | +| 业务事实 | 默认关闭;生产 `runtime.sh business-logs-enable` / `business-logs-up`,本地 `make business-logs-demo-up`;[配置、实收查询、排障与停止](./business-event-logs.md) | +| 指标 | 默认仅 health/info;`SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus` 后重建 server,使用管理员会话查询 | ### 10.1 通用配置 diff --git a/docs/business-event-logs.md b/docs/business-event-logs.md index 2188caa97..2e392fcab 100644 --- a/docs/business-event-logs.md +++ b/docs/business-event-logs.md @@ -2,6 +2,12 @@ Default off. This is lossy analytics, NOT audit, billing, notification or installation confirmation. Use the official server image built from this change; no custom application code, vendor SDK or Collector is required. The receiver must support OTLP/HTTP protobuf Logs. / 默认关闭;用于有损分析,不替代审计、计费、可靠通知或安装确认。只需对应版本官方镜像与运维配置,Collector 可选。 +## Choose a path / 选择路径 + +- Production: `runtime.sh business-logs-enable` connects an existing receiver; `business-logs-up` installs an optional private Collector with an explicit downstream. See [production integration](#production-integration). +- Local trial: `make business-logs-demo-up` supports `make dev-server` or a containerized backend. See [local trial](#local-trial). +- Both paths include configuration updates, new-event queries, metrics, troubleshooting and cleanup. Headers stay in deployment-owned files / 两条路径均包含配置更新、新事件查询、指标、排障与清理,请求头保留在部署侧。 + ## Event catalog / 事件目录 (contract version 1) Every row requires `skillhub.event.name`, `skillhub.skill.id`, `skillhub.namespace.id`, `skillhub.visibility`, `skillhub.source`, `skillhub.actor.type`, `log.record.uid` and producer occurrence `Timestamp`. IDs are numeric except the local opaque human `user.id` and UUID UID. Actor type is `user` (including anonymous/unknown user requests) or `system`. A known human adds `user.id`; anonymous/unknown and system actors omit it. / 所有事件都有上述字段;主体类型为 user(含匿名或未知请求)或 system;已知人类操作者额外使用本实例不透明 `user.id`,匿名、未知及系统主体省略该字段。 @@ -65,28 +71,87 @@ Related existing Resource settings: `spring.application.name` (`skillhub`, envir A generic OTLP base endpoint normally appends a signal path in OTel autoconfiguration; THIS setting is Logs-specific, uses the given URL unchanged, and requires an explicit non-root path. Invalid enabled configuration fails startup without echoing endpoint/header values. Localhost/private-network receivers are valid. Destination is trusted operator configuration, not an end-user setting: use egress firewall/NetworkPolicy and DNS controls to allow only the authorized receiver and block cloud metadata, including aliases/rebinding. The managed 1.31 OkHttp sender follows redirects and exposes no redirect switch on its public Logs exporter builder: configure the receiver not to redirect and enforce destination/HTTPS restrictions at egress. Literal validation is not a complete SSRF firewall. / 本配置不是通用 base endpoint,不追加信号路径;允许自托管内网目标。1.31 发送器会跟随重定向,公开 builder 无关闭开关,接收端应禁止重定向,并配合出口/DNS/HTTPS 策略;字面量校验不能替代防火墙。 -## Direct receiver / 直连部署 + -For an OTLP/HTTP Logs-compatible receiver, set these on the official server container; tracing may remain off: +## Production integration / 生产接入 -```dotenv -SKILLHUB_BUSINESS_LOGS_ENABLED=true -SKILLHUB_BUSINESS_LOGS_ENDPOINT=https://analytics.example.com/v1/logs -SKILLHUB_BUSINESS_LOGS_NAMESPACE_ALLOWLIST=7,12 -SKILLHUB_BUSINESS_LOGS_EVENT_ALLOWLIST=skill.publication.effective,skill.download.prepared,skill.review.approved +### Retrieve and apply / 获取与应用 + +使用包含本功能的同一版本脚本、Compose 和镜像。正式发布后把 `REF`、`VERSION` 固定为同一发布标签。开发验证可使用 `main` / `edge`。脚本的 `--version ` 默认从同名 Git 标签取文件;显式 `--ref` 优先。浮动镜像别名需要显式指定文件版本。 + +```sh +REF=main +VERSION=edge +RUNTIME_HOME="$HOME/skillhub-runtime" +curl -fsSL "https://raw.githubusercontent.com/iflytek/skillhub/$REF/scripts/runtime.sh" -o runtime.sh +sh runtime.sh ps --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" +# 在仓库外编辑 $RUNTIME_HOME/.env.release:数据库、存储、管理员和公开地址。 +chmod 600 "$RUNTIME_HOME/.env.release" +sh runtime.sh up --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" ``` -Use the release Compose file plus deployment-controlled environment/Secret injection. Never commit real `HEADERS`; e.g. its format is `Authorization=Bearer%20` (encode comma, equals, percent and literal plus as needed; `+` decodes to space). Do not run `docker compose config` into shared logs with secrets loaded. Across trust boundaries require HTTPS and receiver-supported authentication. JVM default trusted certificates apply; for private CAs use a deployment-provided JVM truststore, not an insecure TLS bypass. mTLS can be terminated by a local Collector/proxy. / 通过部署侧 Secret 注入请求头;跨信任边界必须 HTTPS/鉴权,私有 CA 使用 JVM truststore;不要关闭 TLS 校验,不要公开渲染含密钥的 Compose 配置。 +默认 `SKILLHUB_BUSINESS_LOGS_ENABLED=false`,不启动 Collector,不创建业务日志网络,也不向接收端发送请求。已部署实例升级时先拉取包含本功能的镜像,再用 `up` 重建。仅 `restart` 不读取新的 Compose 环境变量。 -Docker Secret alternative (no credential in Compose interpolation): mount a Secret whose target filename is `skillhub.observability.business-logs.headers`, and set `SPRING_CONFIG_IMPORT=optional:configtree:/run/secrets/`. Spring config-tree keys override the lower-priority application default. Do not simultaneously define a nonempty `SKILLHUB_BUSINESS_LOGS_HEADERS`. Manage the actual Secret file outside the repository and protect access to Actuator/config diagnostics. / 可用 Docker Secret + Spring configtree,文件名即完整属性名;真实 Secret 文件放仓库外,不与非空环境变量重复设置。 +已有兼容 OTLP/HTTP protobuf Logs 接收端时,在部署侧 `.env.release` 配置过滤范围与请求头,然后执行: -Helm exposes `server.businessLogs.enabled`, `endpoint`, and `headersSecretRef.{name,key}`. The chart references an EXISTING Secret and does not create inline header secrets. Use existing `server.extraEnv` for every tuning/filter/Resource/Trace environment variable in the table. Do not duplicate the three built-in env names in extraEnv. / Helm 提供开关、目标及现有 Secret 引用;预算/过滤等使用 `server.extraEnv`,不要重复内建环境变量。 +```sh +sh runtime.sh business-logs-enable --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" \ + --logs-endpoint https://receiver.example.com/v1/logs +# 同一 Docker 主机上的接收容器可使用已有私有网络;脚本自动检查或创建网络并重建 server。 +# sh runtime.sh business-logs-enable ... --logs-network receiver-private --logs-endpoint http://receiver:4318/v1/logs +``` + +`--logs-endpoint` 是完整 Logs URL,原样使用,通常以 `/v1/logs` 结尾。已有远端接收器不需要本地 Collector。容器接收器在其自身部署中声明共享的外部网络;SkillHub 侧由小型 `compose.release.business-logs.yml` 自动接入。正常 `up` 会保留这个 overlay。每个实例使用独立网络名与项目名。 + +所有请求头保留在部署侧。应用的 `SKILLHUB_BUSINESS_LOGS_HEADERS` 是 URL 编码的 `name=value` 对,以逗号分隔。`+` 解码为空格;逗号、等号、百分号和字面量加号需编码。真实值不进入仓库、命令行、截图或共享日志。不要把加载了密钥的 `docker compose config` 输出到日志。跨信任边界使用 HTTPS 与接收端鉴权。私有 CA 使用部署提供的 JVM truststore;mTLS 可由本地 Collector / 代理终止。 + +Docker Secret 也可挂载为 `/run/secrets/skillhub.observability.business-logs.headers`,并通过部署 overlay 设置 `SPRING_CONFIG_IMPORT=optional:configtree:/run/secrets/`。同时保持 `SKILLHUB_BUSINESS_LOGS_HEADERS` 为空。Secret 文件放在仓库外。 + +### Optional production Collector / 可选生产 Collector + +此路径使用独立 Compose 项目、外部私有网络、128 MiB 内存上限和 0.5 CPU。Collector 不发布主机端口。生产配置只有明确的 `otlphttp` 下游出口,没有 JSON 文件存储。 + +```sh +sh runtime.sh business-logs-up --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" +# 首次运行创建 business-logs/.env(0600),提示填写下游后退出,不启动半配置的 Collector。 +# 在部署侧编辑 $RUNTIME_HOME/business-logs/.env: +# SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://receiver.example.com +# SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{}' +# 有鉴权时把 headers 改成部署侧 JSON 对象,例如 Authorization 键;不在命令行传值。 +sh runtime.sh business-logs-up --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" +sh runtime.sh business-logs-ps --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" +``` + +下游设置是 OTLP base URL;Collector 自动追加 `/v1/logs`。应用指向 `http://business-logs-collector:4318/v1/logs`。脚本从同一 `REF` 获取根目录下的 `compose.business-logs-collector.yml`、`business-logs-collector.yaml` 和网络 overlay,自动接网并重建 server。下游负责持久存储、查询、访问控制和保留期。默认模板使用内存队列;生产容量与持久发送队列由部署方按接收系统配置。 + +更新 `.env.release` 后,重跑 `business-logs-enable` 应用地址、过滤或指标配置。更新 `business-logs/.env` 后,重跑 `business-logs-up` 应用下游配置。脚本保留已有文件中的密钥,不覆盖 `.env`。需要自定义 Collector 管道时,维护部署方自己的 Compose / 配置文件;脚本会更新下载的模板。 + +`--aliyun` 使用扁平 OSS 根目录。发布流程须将上述三个新文件与原有 `runtime.sh`、`compose.release.yml`、`.env.release.example` 一起上传,版本与镜像一致。OSS 根目录不是按 Git ref 划分的版本仓库,发布方负责匹配。 + +### Produce, query and stop / 产生、查询与停止 + +以超级管理员登录,发布一个新版本,或通过 `/api/v1/skills///download` 下载已发布技能。没有历史补发。接收端按 `skillhub.event.name=skill.publication.effective` / `skill.download.prepared` 查询新时间窗口,查看 `skillhub.skill.id`、`skillhub.namespace.id`、`log.record.uid`。OTLP 是写入协议,没有统一的读 API;生产查询使用配置的下游系统自身的查询界面或 API。下文的本地路径提供可直接执行的 JSON 查询。 + +```sh +sh runtime.sh business-logs-logs --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" +# Ctrl-C 退出跟随日志。检查固定告警和接收端健康,不输出部署密钥。 +sh runtime.sh business-logs-disable --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" +sh runtime.sh business-logs-down --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" +# 再启动:已有 SkillHub 依赖先 up,再 business-logs-up;已有下游数据和网络保留。 +``` + +`business-logs-disable` 重建 server 并关闭导出。`business-logs-down` 只停止可选 Collector;单独停止它时应用仍会尝试导出。`down` 停止 SkillHub,保留数据卷。已配置业务日志接收端或可选 Collector 时,`clean` 也保留部署环境、密钥和 Collector 文件。彻底删除前先停两个项目,备份部署配置,再由运维显式删除自己创建的项目和网络。 + +### Helm endpoint and Secret / Helm 接入 + +复用已有接收器,不安装 Collector 子 Chart。先在目标 namespace 创建 Secret:`kubectl create secret generic business-logs-credentials --from-file=headers=/secure/otlp-headers.txt -n skillhub`。文件内容为应用使用的 URL 编码头,不是 Collector 的 JSON 格式。此文件由部署方管理。 ```yaml server: + actuatorExposureInclude: health,info,metrics,prometheus businessLogs: enabled: true - endpoint: https://analytics.example.com/v1/logs + endpoint: https://receiver.example.com/v1/logs headersSecretRef: name: business-logs-credentials key: headers @@ -95,35 +160,78 @@ server: value: "7,12" ``` -## Optional Collector and real receive/query example / 可选 Collector 实收查询 +将片段保存为部署侧 `business-logs-values.yaml`。执行 `helm upgrade --install skillhub charts/skillhub -n skillhub -f production-values.yaml -f business-logs-values.yaml`,再执行 `kubectl rollout status deployment/skillhub-server -n skillhub`。按上面的方式产生新事件并在下游查询。Secret 轮换后执行 `kubectl rollout restart deployment/skillhub-server -n skillhub`。停用时将 `enabled` 改为 `false` 后再次 upgrade;现有 Secret 和下游数据保留。其它预算、过滤和 Resource / Trace 配置使用 `server.extraEnv`,不重复内建变量。 -The pinned example is `docs/examples/business-event-logs/compose.yaml` (Collector contrib `0.111.0`, 128 MiB / 0.5 CPU, only local HTTP port). It writes OTLP JSON to an ephemeral file, NOT production durable storage. Do not expose its unauthenticated port externally. It is separate from release Compose and is not started by default. / 示例仅本地验证,JSON 文件随容器删除,不是生产持久存储;无鉴权端口不得对外开放,不默认随正式部署启动。 + -From repository root, with port 4318 free: +## Local trial / 本地试用 -```bash -docker compose -f docs/examples/business-event-logs/compose.yaml up -d -# Host-run app: SKILLHUB_BUSINESS_LOGS_ENDPOINT=http://127.0.0.1:4318/v1/logs -# Docker Desktop server container: use host.docker.internal:4318 instead of localhost. -# Linux containers: use a shared private network / explicit host-gateway routing. -# Enable business Logs on the app; publish/approve or request an authorized published download. -docker cp skillhub-1009-collector:/business-logs.json ./business-logs.jsonl -jq -r '.resourceLogs[].scopeLogs[].logRecords[] | (.attributes | map({key:.key,value:(.value.stringValue // .value.intValue // .value.boolValue)}) | from_entries) as $a | [.timeUnixNano, $a["skillhub.event.name"], $a["skillhub.skill.id"], $a["log.record.uid"]] | @tsv' business-logs.jsonl -docker compose -f docs/examples/business-event-logs/compose.yaml down +需要 Docker Compose、JDK 21、Maven wrapper 和 jq。从源码根目录执行。示例 Collector 固定为 contrib `0.111.0`,仅发布回环地址。JSON 追加写入项目独立的本地命名卷,没有保留策略,仅用于临时验证。 + +### Host Java process / 主机 Java 进程 + +```sh +make dev +make business-logs-demo-up +make dev-server ``` -For a source-verifiable run of the REAL production exporter wiring against the same Collector config, without bootstrapping business accounts: +`business-logs-demo-up` 保存本地试用标记。`make dev-server` 自动使用 `http://127.0.0.1:4318/v1/logs` 并打开业务日志;无需编辑应用源码或手工设置接收地址。后端已运行时重新启动它。需要其它端口时,对整个试用过程统一设置 `SKILLHUB_BUSINESS_LOGS_DEMO_PORT`;需要并行实例时还统一设置 `SKILLHUB_BUSINESS_LOGS_DEMO_PROJECT`、`SKILLHUB_BUSINESS_LOGS_NETWORK` 和 `BUSINESS_LOGS_DEMO_STATE_DIR`。 -```bash -cd server -TESTCONTAINERS_RYUK_DISABLED=true ./mvnw -pl skillhub-app -am test \ - -Dtest=BusinessLogsCollectorTest -Dsurefire.failIfNoSpecifiedTests=false \ - -Dskillhub.test.evidence-dir=/absolute/operator-owned/evidence +### Containerized backend / 容器后端 + +先用生产路径启动一个独立试用实例。它使用同一份 release Compose 和应用配置。然后从源码根目录设置该实例的环境文件和项目名: + +```sh +export BUSINESS_LOGS_BACKEND=container +export BUSINESS_LOGS_BACKEND_PROJECT=skillhub-local-trial +export BUSINESS_LOGS_BACKEND_ENV_FILE="$HOME/skillhub-local-trial/.env.release" +# BUSINESS_LOGS_BACKEND_PROJECT 必须与已启动实例的 COMPOSE_PROJECT_NAME 相同。 +make business-logs-demo-up ``` -Create that evidence directory first. The test uses named disposable containers and copies the actual sink output to `collector-received.jsonl`; apply the same jq query. It exercises all nine catalog records in all three Trace configurations (SDK trace sampling 0), checks IDs, UID uniqueness, field exclusion and receiver ACK metrics. This is real exporter/receiver integration, not a claim of a complete browser/CLI deployment smoke or loading an external Java agent. / 该测试使用真实应用导出装配及真实 Collector,保存实收结果,可用相同 jq 查询;不等同于完整 Web/CLI 部署验收或真实 Java Agent 注入测试。 +Makefile 自动创建试用私有网络,通过同一个 release 网络 overlay 重建 server,并使用 `http://business-logs-collector:4318/v1/logs`。保留这些变量,以便后续命令操作同一实例。需要指定不同版本的基础 Compose 时设置 `BUSINESS_LOGS_BACKEND_COMPOSE`。此模式的启用是本地试用状态;正常生产 `runtime.sh up` 仍以 `.env.release` 为准。 -Collector is optional. Production operators choose exporters, retention, TLS/auth, memory limiter and persistent sending queues appropriate to their sink. Collector persistence cannot recover records lost before application admission. File demo has no retention policy and must be stopped after verification. / 生产出口、保留策略、持久队列由运维选择;Collector 持久化不能补回应用侧漏报,示例用后停止。 +### Query and cleanup / 查询与清理 + +在本地界面登录管理员,发布新版本或下载已发布技能,然后执行: + +```sh +make business-logs-demo-status +make business-logs-demo-records +make business-logs-demo-verify +# 默认验证 skill.download.prepared;也可指定其它已产生的事件。 +make business-logs-demo-verify BUSINESS_LOGS_EXPECT_EVENT=skill.publication.effective +# 仅取 JSON 行进行结构化查询: +make -s business-logs-demo-records | jq -r '.resourceLogs[].scopeLogs[].logRecords[] | (.attributes | map({key:.key,value:(.value.stringValue // .value.intValue // .value.boolValue)}) | from_entries) as $a | [$a["skillhub.event.name"], $a["skillhub.skill.id"], $a["log.record.uid"]] | @tsv' +make business-logs-demo-down +``` + +`records` 输出实际 JSON 行;`verify` 在缺少预期事件时返回非零。`up` 重建 Collector 以应用配置,命名卷与追加模式保留已有记录。`down` 停止进程,再次 `up` 后仍可查询原记录。主机进程模式停止后还需重启 Java 进程以关闭导出;容器模式自动重建并关闭导出。试用结束后及时停止;需要保留的记录先保存到仓库外。彻底删除时先停止后端,再用该试用项目名执行 `docker compose -p -f docs/examples/business-event-logs/compose.yaml down -v`,显式删除试用卷,最后删除已无容器连接的试用网络。命名卷不是生产存储或可靠投递保证。 + +### Metrics enablement and admin query / 指标启用与管理员查询 + +默认只暴露 `health,info`。即使已有业务事件,管理员访问 metrics / prometheus 也会得到 404。Compose 在 `.env.release` 设置 `SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus`,然后通过 `business-logs-enable`(收集启用时)或正常 `up` 重建 server。Helm 使用上面的 `server.actuatorExposureInclude`。主机 Java 进程则在启动前导出同名变量。不要使用 `*`。 + +源代码验证先执行 `make build-backend`,再执行 `docker build -t skillhub-server:local -f server/Dockerfile server`,并让 runtime 的 `--server-image skillhub-server --version local --ref <对应源码ref>` 指向新镜像。正式镜像升级使用发布标签,不依赖旧镜像中的新配置。 + +可复用浏览器中的超级管理员会话。命令行查询时,在仓库外准备仅本人可读的登录 JSON 文件,内容为管理员 username / password。以下命令不打印密码或 Cookie: + +```sh +BASE=http://127.0.0.1:8080 +QUERY_DIR=$(mktemp -d) +chmod 700 "$QUERY_DIR" +# LOGIN_JSON=/secure/admin-login.json,文件 mode 600;由部署方创建。 +curl -fsS -c "$QUERY_DIR/cookies" "$BASE/api/v1/auth/methods" -o /dev/null +CSRF=$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$QUERY_DIR/cookies") +curl -fsS -b "$QUERY_DIR/cookies" -c "$QUERY_DIR/cookies" -H "X-XSRF-TOKEN: $CSRF" \ + -H 'Content-Type: application/json' --data-binary @"$LOGIN_JSON" "$BASE/api/v1/auth/local/login" -o /dev/null +curl -fsS -b "$QUERY_DIR/cookies" "$BASE/actuator/metrics/skillhub.business.logs.enqueued" +curl -fsS -b "$QUERY_DIR/cookies" "$BASE/actuator/prometheus" +rm -rf "$QUERY_DIR" +``` + +超级管理员可查询这两个指标入口。普通用户返回 403,匿名请求返回 401。Prometheus 沿用已有 `AUDITOR` 权限;单项 metrics 仅超级管理员。未启用业务导出时不会创建这些业务计数器;启用但未产生事件时计数为零。计数器是当前进程累计值,重启后归零,含义见下表。计数器反映应用接纳 / SDK 确认,不替代下游实收查询。 ## Reliability, metrics and troubleshooting / 可靠性、指标与排障 @@ -139,7 +247,7 @@ Graceful shutdown stops new admission, gives SDK drain a `shutdown-timeout` wait OTel 1.31 treats all HTTP 2xx responses as full success and does not expose `partial_success` rejected counts. `exported` means SDK/receiver ACK, NOT downstream storage confirmation. Do not replay whole partially accepted batches. Partial rejection diagnostics and durable retries belong at the receiver; this change does not upgrade the BOM. / 1.31 不读取部分拒收数量;成功指标不代表最终落库,不整批重发部分接收的数据。 -Micrometer names on the existing protected Actuator metrics surface (Prometheus renders dots as underscores): +Micrometer names on the existing protected Actuator metrics surface (Prometheus renders dots as underscores and appends `_total` for counters, e.g. `skillhub_business_logs_enqueued_total`): | Metric | Unit / 含义 | | --- | --- | @@ -160,6 +268,8 @@ Troubleshoot using the protected Actuator counters above, this fixed warning, re | Symptom / 现象 | Check / 排查 | | --- | --- | +| Metrics 404 / 指标 404 | 显式启用 exposure 列表并重建镜像/容器;指标权限与导出开关独立。默认 404 是预期行为 | +| Demo no records / 本地无记录 | 先运行 demo-up;主机模式重启 make dev-server;容器模式确认项目名、env 文件与共享网络,避免把容器 localhost 当接收器 | | Startup fails / 启动失败 | Enabled without endpoint, bad full URL, invalid bounds/allowlist/header encoding; do not paste config secrets / 检查目标、范围、编码,不分享 Secret | | No records, no exporter metrics / 无记录或指标 | Default off? Environment explicitly forwarded? Container recreated? No historical backfill / 开关、透传、重建、新事件 | | Enqueued but failed / 已入队但失败 | `/v1/logs`, HTTP/protobuf (not gRPC 4317), DNS/network/TLS/auth and receiver Logs pipeline / 路径、协议、网络及鉴权 | diff --git a/docs/skillhub/en/guide/deployment.md b/docs/skillhub/en/guide/deployment.md index bc2d92328..fba0a7533 100644 --- a/docs/skillhub/en/guide/deployment.md +++ b/docs/skillhub/en/guide/deployment.md @@ -40,13 +40,13 @@ Suite review writes are enabled by default in the single-server release Compose ## Optional business-event OTLP Logs -Business analytics is disabled by default and independent of all three Trace modes. With the matching -official server image, enable `SKILLHUB_BUSINESS_LOGS_ENABLED=true` and a full -`SKILLHUB_BUSINESS_LOGS_ENDPOINT`; Collector is optional and authentication headers belong in deployment -Secrets. See the [business-event operations guide](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md) -for publication, download preparation, review, promotion, archive, visibility and yank semantics, private-ID -scope, every setting, a real receive/query example and loss/duplicate/shutdown limits. Download preparation -is not completion or installation; this lossy stream is not audit, billing or reliable notification. +Business analytics stays off by default and independent of tracing. Use `runtime.sh business-logs-enable` +for an existing receiver, or `business-logs-up` for an optional Collector with an explicit downstream. +For a local host Java process or container backend, use `make business-logs-demo-up`. +Metrics require the explicit `health,info,metrics,prometheus` exposure list and a server recreation. +The [operations guide](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md) covers both +paths, Helm Secrets, admin queries, troubleshooting and cleanup. Download preparation is not completion +or installation; this lossy stream is not an audit or billing system. ## CLI device authorization URL diff --git a/docs/skillhub/guide/deployment.md b/docs/skillhub/guide/deployment.md index 4b17983ed..40691c222 100644 --- a/docs/skillhub/guide/deployment.md +++ b/docs/skillhub/guide/deployment.md @@ -40,12 +40,11 @@ Suite 审核写入在单机 release Compose 中默认开启;Suite Bundle 确 ## 可选的业务事件 OTLP Logs -业务分析默认关闭,与三种 Trace 模式独立。对应版本官方镜像可通过 -`SKILLHUB_BUSINESS_LOGS_ENABLED=true` 和完整 `SKILLHUB_BUSINESS_LOGS_ENDPOINT` 启用; -Collector 可选,请求头凭证使用部署 Secret。发布、下载准备、审核、提升、归档、可见性和 yank 的 -精确定义、私有 ID 范围、完整配置表、实收查询示例及丢失/重复/退出限制见 -[业务事件运行手册](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md)。 -下载准备不是下载完成或安装成功,不能用于审计、计费或可靠通知。 +业务分析默认关闭,与 Trace 独立。生产使用 `runtime.sh business-logs-enable` 接已有接收端, +或用 `business-logs-up` 启动有明确下游的可选 Collector。本地使用 `make business-logs-demo-up`, +自动适配主机 Java 与容器后端。指标需显式启用 `health,info,metrics,prometheus` 并重建 server。 +[运行手册](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md) 提供生产接入、 +本地试用、Helm Secret、管理员查询、排障与停止步骤。下载准备不代表完成或安装成功。 ## 升级、停止与排障 From c327ea2e07363a39fe2e3205bfd57bd335afc5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 17:05:44 +0800 Subject: [PATCH 17/21] docs: record Repair 4 executed acceptance and remaining limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- .../acceptance.md | 41 +++++++++++++++++++ .../add-otlp-business-event-logs/tasks.md | 16 ++++++++ 2 files changed, 57 insertions(+) diff --git a/openspec/changes/add-otlp-business-event-logs/acceptance.md b/openspec/changes/add-otlp-business-event-logs/acceptance.md index d0987a53e..e87eba0f5 100644 --- a/openspec/changes/add-otlp-business-event-logs/acceptance.md +++ b/openspec/changes/add-otlp-business-event-logs/acceptance.md @@ -109,3 +109,44 @@ The 13 added denial cases and two mapper edge assertions were finalized after th Docs site build was attempted. VitePress was absent; `npm ci` was blocked by the environment's network guard for both the lockfile registry and an explicit npmjs alternative. This is reported as BLOCKED, not passing. Markdown/source/link checks are separate from a built site. / 文档站构建受依赖下载网络限制,未冒称通过。 Not executed: real external Java-agent/plugin installation, TLS/mTLS production deployment, a browser/CLI deployment smoke, forced-process-crash recovery or sustained throughput/heap profiling. There is no outbox/replay guarantee to test. Agent duplicate suppression, authorized egress/DNS/redirect policy, production Secret provisioning, receiver persistence and retention remain deployment responsibilities. All application mode combinations were exercised without installing a Java agent. / 未执行真实 Agent 注入、生产 TLS/mTLS、完整 Web/CLI 部署或持续负载测试;相关限制和部署责任明确保留。 + +## Repair 4 — deployment integration and test isolation + +The maintainer's deployed receive test is the starting point, not a substitute for this round's executions. Evidence filenames below refer to the separate worker evidence directory; credentials and host-specific paths are not committed. + +### Process-global diagnostics test + +The unmodified minimal selector was run twice with Surefire's default filesystem ordering: Trace ran first and both runs passed 18 tests. With Surefire's real `alphabetical` ordering, diagnostics ran first and both RED runs had **18 tests, 1 failure, 0 errors, 0 skips**. The failure is `SkillHubTracingConfigurationTest.otelSdkScopeShouldPublishTraceCorrelationToMdc`, with a null MDC trace ID. The diagnostics fixture changes global Boot Logback/JUL state and creates raw SDK spans before Boot installs the OTel ContextStorage MDC wrapper. Reloading Logback does not reset that process-global SDK initialization. The managed ContextStorage wrapper behavior was inspected from the actual dependency bytecode. + +The fixture is now tagged and runs in a dedicated, non-reused Surefire fork. The ordinary execution excludes that tag; the isolated execution includes only the diagnostics class. All original assertions remain. This is isolation, not a claim of restoring every global singleton in-process. + +| Executed set | Final Surefire XML totals | Evidence | +| --- | --- | --- | +| Baseline minimal selector, alphabetical, twice | 18 each; 1 failure, 0 errors/skips | `red-3.log`, `red-4.log`, corresponding XML directories | +| Isolated minimal selector, alphabetical, twice | 18 each; 0 failures/errors/skips | `green-1.log`, `green-2.log`, corresponding XML directories | +| Exact Repair 3 selector (now also matches the new metrics security class) | 312; 0 failures/errors/skips | `focused.log`, `focused-xml/` | +| Entire observability test package | 117; 0 failures/errors/skips | `observability.log`, `observability-xml/` | +| Metrics opt-in/default-off security tests | 3; 0 failures/errors/skips | `metrics-java-final.log`, `metrics-xml/` | + +### Real deployment paths versus template contracts + +| Path | Real execution and assertion | Evidence | +| --- | --- | --- | +| Branch server image | Built with `server/Dockerfile`, the same context/Dockerfile as image CI; inspected the resulting arm64 image. Later server edits were test-only | `docker-build.log`, `image-inspect.log` | +| Default off | Real bootstrap-admin PRIVATE publication and authenticated download return 200; existing receiver has 0 requests; no optional Collector; admin metrics/prometheus return 404 | `real-runtime-green2.log`, `default-off-received.json` | +| Existing receiver | Independent plain OTLP/HTTP receiver on the private network receives the new publication and download; 2 records queried | `real-runtime-green2.log`, `existing-received.json` | +| Application config update | Change endpoint to a second receiver, rerun enable; old receiver stays at 2 records, new receiver receives 2; deployment header bytes unchanged | `real-runtime-green2.log`, `endpoint-update-received.json` | +| Optional production Collector | No host bindings, 128 MiB / 0.5 CPU, explicit downstream; downstream count rises from 2 to 4 records | `real-runtime-green2.log`, `collector-inspect.log`, `optional-collector-received.json` | +| Stop and start | Stop Collector and SkillHub, then start both; external network ID, deployment env bytes and existing records retained; download the pre-existing package successfully, downstream count rises from 4 to 5 | `real-runtime-green2.log`, `stop-start-received.json` | +| Disable | Recreate with collection off; new publication/download return 200 and receiver count stays at 5 | `real-runtime-green2.log`, `disabled-received.json` | +| Collector config update | Change downstream endpoint, rerun up; new target rises from 2 to 4 records while old target stays at 5; downstream header bytes unchanged. Disable again and prove no new exports | `downstream-update.log`, `downstream-update-received.json`, `final-disabled-received.json` | +| Host process | Actual `make dev-server`, offline wrapper packaging, real PostgreSQL/Redis, scanner disabled; automatic loopback receive address; new publication/download and records/verify pass, including rerunning demo up | `host-dev-final.log`, `host-query-final.log` | +| Containerized backend | Make targets recreate the branch backend on the shared private network; actual JSON contains publication/download for the new skill ID; stop/start verify retains prior records | `demo-container-final.log`, `demo-container-records.log` | +| Metrics | Explicit exposure list; real super-admin session gets 200 for business counter and Prometheus, normal session 403, anonymous 401. Counter and scrape both show 2 enqueued/exported | `real-runtime-green2.log`, `metrics-counter.txt`, `metrics-prometheus.txt` | +| Cleanup | Real clean retains both operator env files byte-for-byte at mode 600; owned project containers, named volumes, networks and test images removed; pre-existing dind images preserved; authorized dind and local relay stopped | `cleanup.log`, `cleanup-summary.json`, `local-port-cleanup.json` | + +A real demo stop/start initially failed because the file exporter truncates its file by default. The fix uses `append: true` and a project-scoped local trial volume; up recreates the Collector so config-file changes apply without losing those trial records. This is not production storage. The real Collector Java test now restarts the container, waits for its HTTP pipeline and asserts all 27 original UIDs remain. Docker changed the dynamic host port on this host; an intermediate new test probe used stale cached mapping and failed. The final test inspects the actual restarted binding. Both the full focused run and observability package pass with this assertion (`collector-restart-final.log`, final suite logs). + +Contract checks are separate: the fake-Docker lifecycle script covers default off, existing receiver, optional Collector, repeated up, updates, stop/start, disable, permissions, secret retention, flat OSS paths, and both Make modes. Python deployment checks pass 6 cases, including rendered private-network/volume/metrics settings. Helm configuration contracts and strict lint pass; the supplied minimal Helm image command required adding Python 3 for the chart's pre-existing Python contract checks. Bash 3's pre-existing empty-array issue in the storage selector test was avoided by running that test in a Linux Bash container. No production data or credentials were used. + +`npm ci`, the VitePress build, weekly site generation/validation and `git diff --check` pass. No full backend rerun, browser/CLI E2E, production TLS/mTLS, live Helm install/upgrade, real external Java agent or durable downstream storage validation is claimed. Host dev verification intentionally used only PostgreSQL/Redis and PRIVATE admin publications rather than the full scanner/frontend stack. Versioned/flat retrieval was exercised through the same download function with a local file root because these new files are not published yet; OSS/release upload and PR publication remain with the owner. No push or PR mutation was performed. diff --git a/openspec/changes/add-otlp-business-event-logs/tasks.md b/openspec/changes/add-otlp-business-event-logs/tasks.md index 82241d519..b6bf4afea 100644 --- a/openspec/changes/add-otlp-business-event-logs/tasks.md +++ b/openspec/changes/add-otlp-business-event-logs/tasks.md @@ -45,3 +45,19 @@ Repair 1 results and intermediate failures are recorded in acceptance.md and the - [x] Rerun full focused selector/environment: 307 tests green (81 business); real Collector query 27/9/27; deployment contracts 4/4 plus Helm/release shell suites green; diff and scoped cleanup checked. Repair 2 exact commands/counts/evidence and proposed fixup are appended to the worker report. No commit/push/comment was performed. + +## Repair 4 + +- [x] Run the unmodified Diagnostics + Trace selector twice in default ordering and twice in diagnostic-first Surefire alphabetical ordering; retain the reproduced MDC failure and XML counts. +- [x] Isolate process-global diagnostics in a dedicated, non-reused test fork without removing assertions; final minimal combination passes twice, exact focused selector passes 312 tests, full observability package passes 117. +- [x] Add default-health/info metrics exposure passthrough in application, Compose and Helm with validation/schema contracts; prove live super-admin 200, ordinary-user 403, anonymous 401, and default admin 404. +- [x] Add POSIX runtime entry points for existing receiver and optional downstream-forwarding Collector, shared private network overlay, repeatable updates/status/stop/disable, protected deployment env files and flat OSS retrieval contracts. +- [x] Add Makefile demo entry points with automatic host/container endpoint selection, actual JSON queries and expected-event verification; preserve trial records with append mode and a project-scoped local volume across up/stop/start. +- [x] Execute a branch Dockerfile build and real bootstrap-admin publication/download matrix for default off, existing receiver, optional Collector, application endpoint update, Collector downstream update, stop/start and disable on an isolated non-default-port deployment. +- [x] Execute actual make dev-server with offline packaging, PostgreSQL/Redis and a demo Collector; execute the containerized backend path and prove new skill records plus retained records after restart. +- [x] Extend the real Collector integration test with a restarted HTTP readiness probe and preservation of all 27 original UIDs; handle Docker's changed dynamic host-port binding; final broad focused/package runs include the assertion. +- [x] Run fake-Docker lifecycle tests, six Python deployment contracts, relevant existing script suites, Helm contracts/strict lint, VitePress and weekly site validation; record platform prerequisite failures separately from passing reruns. +- [x] Rewrite the guide as production integration/local trial, add short site/chart pointers, admin queries/counter meanings, operational updates and cleanup steps; document required release/OSS file upload. +- [x] Verify real clean retains deployment files byte-for-byte at mode 600; remove the identified test containers, project volumes/networks and test images, preserve pre-existing images, stop the authorized dind and local relay, and verify owned ports are closed. + +Repair 4 real runs, template-only checks, intermediate fixture failures and remaining deployment limits are recorded in acceptance.md and the worker report. Commits remain local; publication is left to the owner. From 55b2354e892d93630230a25f7ac7aa5f827ed10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 17:39:08 +0800 Subject: [PATCH 18/21] fix: preserve runtime refs and remembered business logs networks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- .github/workflows/pr-scripts.yml | 2 + docs/business-event-logs.md | 16 +- docs/skillhub/en/guide/deployment.md | 1 + docs/skillhub/guide/deployment.md | 3 +- scripts/runtime.sh | 13 +- .../tests/runtime-review-regression-test.sh | 149 ++++++++++++++++++ 6 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 scripts/tests/runtime-review-regression-test.sh diff --git a/.github/workflows/pr-scripts.yml b/.github/workflows/pr-scripts.yml index d668dc511..01e8e3d43 100644 --- a/.github/workflows/pr-scripts.yml +++ b/.github/workflows/pr-scripts.yml @@ -15,6 +15,7 @@ on: - 'compose.release.business-logs.yml' - 'business-logs-collector.yaml' - 'docs/examples/business-event-logs/**' + - 'docs/business-event-logs.md' - 'compose.storage.*.yml' - 'deploy/storage/**' - 'deploy/runtime-mirror-images.txt' @@ -63,6 +64,7 @@ jobs: - run: bash scripts/tests/runtime-secret-test.sh - run: bash scripts/tests/runtime-analytics-test.sh - run: bash scripts/tests/runtime-business-logs-test.sh + - run: bash scripts/tests/runtime-review-regression-test.sh - uses: azure/setup-helm@v4 with: version: v3.19.0 diff --git a/docs/business-event-logs.md b/docs/business-event-logs.md index 2e392fcab..a538ec4df 100644 --- a/docs/business-event-logs.md +++ b/docs/business-event-logs.md @@ -77,7 +77,7 @@ A generic OTLP base endpoint normally appends a signal path in OTel autoconfigur ### Retrieve and apply / 获取与应用 -使用包含本功能的同一版本脚本、Compose 和镜像。正式发布后把 `REF`、`VERSION` 固定为同一发布标签。开发验证可使用 `main` / `edge`。脚本的 `--version ` 默认从同名 Git 标签取文件;显式 `--ref` 优先。浮动镜像别名需要显式指定文件版本。 +使用包含本功能的同一版本脚本、Compose 和镜像。正式发布后同时传入 `--ref --version `,把 `REF`、`VERSION` 固定为同一发布标签。开发验证可使用 `main` / `edge`。`--version` 只选择镜像标签;`--ref`(或环境变量 `SKILLHUB_REF`,默认 `main`)独立选择文件来源。 ```sh REF=main @@ -122,7 +122,7 @@ sh runtime.sh business-logs-up --home "$RUNTIME_HOME" --ref "$REF" --version "$V sh runtime.sh business-logs-ps --home "$RUNTIME_HOME" --ref "$REF" --version "$VERSION" ``` -下游设置是 OTLP base URL;Collector 自动追加 `/v1/logs`。应用指向 `http://business-logs-collector:4318/v1/logs`。脚本从同一 `REF` 获取根目录下的 `compose.business-logs-collector.yml`、`business-logs-collector.yaml` 和网络 overlay,自动接网并重建 server。下游负责持久存储、查询、访问控制和保留期。默认模板使用内存队列;生产容量与持久发送队列由部署方按接收系统配置。 +下游设置是 OTLP base URL;Collector 自动追加 `/v1/logs`。首次创建独立 env 时,`business-logs-up --logs-network ` 可指定网络;已有 env 的网络与密钥保持不变,后续以该文件为准。应用指向 `http://business-logs-collector:4318/v1/logs`。脚本从同一 `REF` 获取根目录下的 `compose.business-logs-collector.yml`、`business-logs-collector.yaml` 和网络 overlay,自动接网并重建 server。下游负责持久存储、查询、访问控制和保留期。默认模板使用内存队列;生产容量与持久发送队列由部署方按接收系统配置。 更新 `.env.release` 后,重跑 `business-logs-enable` 应用地址、过滤或指标配置。更新 `business-logs/.env` 后,重跑 `business-logs-up` 应用下游配置。脚本保留已有文件中的密钥,不覆盖 `.env`。需要自定义 Collector 管道时,维护部署方自己的 Compose / 配置文件;脚本会更新下载的模板。 @@ -140,7 +140,7 @@ sh runtime.sh business-logs-down --home "$RUNTIME_HOME" --ref "$REF" --version " # 再启动:已有 SkillHub 依赖先 up,再 business-logs-up;已有下游数据和网络保留。 ``` -`business-logs-disable` 重建 server 并关闭导出。`business-logs-down` 只停止可选 Collector;单独停止它时应用仍会尝试导出。`down` 停止 SkillHub,保留数据卷。已配置业务日志接收端或可选 Collector 时,`clean` 也保留部署环境、密钥和 Collector 文件。彻底删除前先停两个项目,备份部署配置,再由运维显式删除自己创建的项目和网络。 +`business-logs-disable` 重建 server 并关闭导出,保留已配置的私有网络连接。之后运行 `business-logs-enable` 即可恢复保存的接收端配置,无需再传网络参数。`business-logs-down` 只停止可选 Collector;单独停止它时应用仍会尝试导出。`down` 停止 SkillHub,保留数据卷。已配置业务日志接收端或可选 Collector 时,`clean` 也保留部署环境、密钥和 Collector 文件。彻底删除前先停两个项目,备份部署配置,再由运维显式删除自己创建的项目和网络。 ### Helm endpoint and Secret / Helm 接入 @@ -162,6 +162,14 @@ server: 将片段保存为部署侧 `business-logs-values.yaml`。执行 `helm upgrade --install skillhub charts/skillhub -n skillhub -f production-values.yaml -f business-logs-values.yaml`,再执行 `kubectl rollout status deployment/skillhub-server -n skillhub`。按上面的方式产生新事件并在下游查询。Secret 轮换后执行 `kubectl rollout restart deployment/skillhub-server -n skillhub`。停用时将 `enabled` 改为 `false` 后再次 upgrade;现有 Secret 和下游数据保留。其它预算、过滤和 Resource / Trace 配置使用 `server.extraEnv`,不重复内建变量。 +Helm 指标查询直接连接 server Service,不经过 Web / ingress。在一个终端保持端口转发(示例 release / namespace 均为 `skillhub`,Service 端口为默认的 8080;自定义名称或端口时对应替换): + +```sh +kubectl port-forward -n skillhub service/skillhub-server 18080:8080 +``` + +在另一个终端设置 `BASE=http://127.0.0.1:18080`,然后执行下文“指标启用与管理员查询”的登录和查询命令,登录与指标请求都使用这个转发地址。无需添加 ingress 路由。关闭转发后,本地访问结束。 + ## Local trial / 本地试用 @@ -218,7 +226,7 @@ make business-logs-demo-down 可复用浏览器中的超级管理员会话。命令行查询时,在仓库外准备仅本人可读的登录 JSON 文件,内容为管理员 username / password。以下命令不打印密码或 Cookie: ```sh -BASE=http://127.0.0.1:8080 +BASE=${BASE:-http://127.0.0.1:8080} QUERY_DIR=$(mktemp -d) chmod 700 "$QUERY_DIR" # LOGIN_JSON=/secure/admin-login.json,文件 mode 600;由部署方创建。 diff --git a/docs/skillhub/en/guide/deployment.md b/docs/skillhub/en/guide/deployment.md index fba0a7533..2099fd77b 100644 --- a/docs/skillhub/en/guide/deployment.md +++ b/docs/skillhub/en/guide/deployment.md @@ -43,6 +43,7 @@ Suite review writes are enabled by default in the single-server release Compose Business analytics stays off by default and independent of tracing. Use `runtime.sh business-logs-enable` for an existing receiver, or `business-logs-up` for an optional Collector with an explicit downstream. For a local host Java process or container backend, use `make business-logs-demo-up`. +For a runtime release deployment, pass `--ref --version ` together: ref selects files; version selects only images. Metrics require the explicit `health,info,metrics,prometheus` exposure list and a server recreation. The [operations guide](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md) covers both paths, Helm Secrets, admin queries, troubleshooting and cleanup. Download preparation is not completion diff --git a/docs/skillhub/guide/deployment.md b/docs/skillhub/guide/deployment.md index 40691c222..67f5c9d7a 100644 --- a/docs/skillhub/guide/deployment.md +++ b/docs/skillhub/guide/deployment.md @@ -42,7 +42,8 @@ Suite 审核写入在单机 release Compose 中默认开启;Suite Bundle 确 业务分析默认关闭,与 Trace 独立。生产使用 `runtime.sh business-logs-enable` 接已有接收端, 或用 `business-logs-up` 启动有明确下游的可选 Collector。本地使用 `make business-logs-demo-up`, -自动适配主机 Java 与容器后端。指标需显式启用 `health,info,metrics,prometheus` 并重建 server。 +自动适配主机 Java 与容器后端。runtime 发布部署同时传 `--ref --version `:前者选文件,后者只选镜像。 +指标需显式启用 `health,info,metrics,prometheus` 并重建 server。 [运行手册](https://github.com/iflytek/skillhub/blob/main/docs/business-event-logs.md) 提供生产接入、 本地试用、Helm Secret、管理员查询、排障与停止步骤。下载准备不代表完成或安装成功。 diff --git a/scripts/runtime.sh b/scripts/runtime.sh index 9d7493dc0..317ed8aeb 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -174,13 +174,6 @@ EOF esac done -if [ "$EXPLICIT_REF" = "false" ] && [ -n "$SKILLHUB_VERSION_VALUE" ]; then - case "$SKILLHUB_VERSION_VALUE" in - latest|edge) ;; # Floating image aliases are not Git refs; pin --ref explicitly. - *) SKILLHUB_REF="$SKILLHUB_VERSION_VALUE" ;; - esac -fi - if [ "$USE_ALIYUN" = "true" ]; then SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://imageless.oss-cn-beijing.aliyuncs.com}" RUNTIME_SCRIPT_URL="$SKILLHUB_RAW_BASE/runtime.sh" @@ -489,7 +482,7 @@ prepare_business_logs_files() { umask 077 { printf 'SKILLHUB_BUSINESS_LOGS_PROJECT=%s\n' "$(get_env_value SKILLHUB_BUSINESS_LOGS_PROJECT skillhub-business-logs)" - printf 'SKILLHUB_BUSINESS_LOGS_NETWORK=%s\n' "$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs)" + printf 'SKILLHUB_BUSINESS_LOGS_NETWORK=%s\n' "${BUSINESS_LOGS_NETWORK:-$(get_env_value SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs)}" printf 'SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=\n' printf "SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_HEADERS='{}'\n" } >"$BUSINESS_LOGS_ENV_FILE" @@ -641,7 +634,7 @@ build_runtime_lifecycle_args() { if [ "$USE_ALIYUN" = "true" ]; then append_runtime_flag "--aliyun" - elif [ "$EXPLICIT_REF" = "true" ] || { [ "$SKILLHUB_REF" != "main" ] && [ "$SKILLHUB_REF" != "$SKILLHUB_VERSION_VALUE" ]; }; then + elif [ "$EXPLICIT_REF" = "true" ] || [ "$SKILLHUB_REF" != "main" ]; then append_runtime_option "--ref" "$SKILLHUB_REF" fi @@ -774,7 +767,7 @@ EOF ;; business-logs-disable) set_env_value SKILLHUB_BUSINESS_LOGS_ENABLED false - set_env_value SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED false + # Attachment intent survives collection being disabled, so enable can reuse it. run_compose up -d --no-deps --force-recreate server echo "Business Logs disabled; receiver data and network retained." ;; diff --git a/scripts/tests/runtime-review-regression-test.sh b/scripts/tests/runtime-review-regression-test.sh new file mode 100644 index 000000000..30e009b59 --- /dev/null +++ b/scripts/tests/runtime-review-regression-test.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Fake-Docker regressions for independent review; no daemon or network required. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +TMP=$(mktemp -d "${TMPDIR:-/tmp}/skillhub-runtime-review.XXXXXX") +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" +export ROOT DOCKER_LOG="$TMP/docker.log" FETCH_LOG="$TMP/fetch.log" +export REAL_CURL +REAL_CURL=$(command -v curl) +cat >"$TMP/bin/docker" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$DOCKER_LOG" +exit 0 +SH +cat >"$TMP/bin/curl" <<'SH' +#!/bin/sh +printf '%s\n' "$2" >>"$FETCH_LOG" +case "$2" in + https://raw.githubusercontent.com/iflytek/skillhub/*) + path=${2#https://raw.githubusercontent.com/iflytek/skillhub/} + # Record the real ref selection, but retrieve fixture files without a network. + exec "$REAL_CURL" "$1" "file://$ROOT/${path#*/}" "$3" "$4" + ;; + *) exec "$REAL_CURL" "$@" ;; +esac +SH +chmod +x "$TMP/bin/docker" "$TMP/bin/curl" +export PATH="$TMP/bin:$PATH" SKILLHUB_RAW_BASE="file://$ROOT" +unset SKILLHUB_REF SKILLHUB_VERSION +run() { sh "$ROOT/scripts/runtime.sh" "$@" >"$TMP/output" 2>&1; } +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +fresh() { + export SKILLHUB_HOME="$TMP/$1" + mkdir -p "$SKILLHUB_HOME" + : >"$DOCKER_LOG" + : >"$FETCH_LOG" +} +assert_env() { + grep -Fxq "$1=$2" "$SKILLHUB_HOME/.env.release" || fail "$1 expected $2" +} + +b1() { + unset SKILLHUB_RAW_BASE + broken=false + for ref in main x; do + fresh "b1-$ref" + # Existing analytics installations fetch this overlay even for ps/down/clean. + touch "$SKILLHUB_HOME/compose.release.analytics.yml" + if [ "$ref" = main ]; then unset SKILLHUB_REF; else export SKILLHUB_REF="$ref"; fi + run ps --version v0.2.25 + printf 'B1 requested URLs (expected ref=%s):\n' "$ref" + cat "$FETCH_LOG" + if ! grep -Fxq "https://raw.githubusercontent.com/iflytek/skillhub/$ref/compose.release.analytics.yml" "$FETCH_LOG"; then + printf 'FAIL: --version changed file ref from %s\n' "$ref" + broken=true + fi + done + # An env ref equal to the image tag must still be carried into lifecycle commands. + run up --version x --no-scanner + if ! grep -q 'sh -s -- ps --ref x --version x' "$TMP/output"; then + printf 'FAIL: lifecycle omitted environment ref equal to version\n' + broken=true + fi + [ "$broken" = false ] || fail 'B1 ref/lifecycle regression' + printf 'PASS B1: analytics ps keeps main/env ref; lifecycle preserves --ref x with --version x\n' +} + +b2() { + export SKILLHUB_RAW_BASE="file://$ROOT" + unset SKILLHUB_REF + broken=false + for receiver in private collector public; do + fresh "b2-$receiver" + run up --no-scanner + case "$receiver" in + private) run business-logs-enable --logs-network synthetic-review-private --logs-endpoint http://existing:4318/v1/logs ;; + collector) + if run business-logs-up; then fail 'unconfigured downstream accepted'; fi + printf 'SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://sink.example.com\n' >>"$SKILLHUB_HOME/business-logs/.env" + run business-logs-up + ;; + public) run business-logs-enable --logs-endpoint https://receiver.example.com/v1/logs ;; + esac + run business-logs-disable + assert_env SKILLHUB_BUSINESS_LOGS_ENABLED false + : >"$DOCKER_LOG" + run business-logs-enable + assert_env SKILLHUB_BUSINESS_LOGS_ENABLED true + if [ "$receiver" = public ]; then + assert_env SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED false + ! grep -q 'compose.release.business-logs.yml\|^network ' "$DOCKER_LOG" || fail 'public HTTPS receiver requires private network' + assert_env SKILLHUB_BUSINESS_LOGS_ENDPOINT https://receiver.example.com/v1/logs + else + grep 'compose .*up -d --no-deps --force-recreate server' "$DOCKER_LOG" + if ! grep -Fxq 'SKILLHUB_BUSINESS_LOGS_NETWORK_ENABLED=true' "$SKILLHUB_HOME/.env.release"; then + printf 'FAIL: B2 %s re-enable lost saved network attachment\n' "$receiver" + broken=true + continue + fi + grep -q 'compose.release.business-logs.yml up -d --no-deps --force-recreate server' "$DOCKER_LOG" || fail "$receiver re-enable lost network overlay" + if [ "$receiver" = private ]; then + assert_env SKILLHUB_BUSINESS_LOGS_NETWORK synthetic-review-private + assert_env SKILLHUB_BUSINESS_LOGS_ENDPOINT http://existing:4318/v1/logs + else + assert_env SKILLHUB_BUSINESS_LOGS_NETWORK skillhub-business-logs + assert_env SKILLHUB_BUSINESS_LOGS_ENDPOINT http://business-logs-collector:4318/v1/logs + fi + fi + printf 'PASS B2 %s: enable -> disable -> enable restores saved state\n' "$receiver" + done + [ "$broken" = false ] || fail 'B2 private/Collector network regression' +} + +n1() { + export SKILLHUB_RAW_BASE="file://$ROOT" + unset SKILLHUB_REF + fresh n1 + if run business-logs-up --logs-network synthetic-review-seeded; then fail 'unconfigured downstream accepted'; fi + grep '^SKILLHUB_BUSINESS_LOGS_NETWORK=' "$SKILLHUB_HOME/business-logs/.env" + grep -Fxq 'SKILLHUB_BUSINESS_LOGS_NETWORK=synthetic-review-seeded' "$SKILLHUB_HOME/business-logs/.env" || fail 'first-time --logs-network ignored' + printf 'SKILLHUB_BUSINESS_LOGS_DOWNSTREAM_ENDPOINT=https://sink.example.com\n' >>"$SKILLHUB_HOME/business-logs/.env" + before=$(shasum -a 256 "$SKILLHUB_HOME/business-logs/.env") + run business-logs-up --logs-network synthetic-review-ignored + [ "$(shasum -a 256 "$SKILLHUB_HOME/business-logs/.env")" = "$before" ] || fail 'existing Collector env rewritten' + assert_env SKILLHUB_BUSINESS_LOGS_NETWORK synthetic-review-seeded + grep -q 'network inspect synthetic-review-seeded' "$DOCKER_LOG" || fail 'seeded network not used' + printf 'PASS N1: first creation seeds CLI network; existing env bytes and saved network retained\n' +} + +n2() { + export SKILLHUB_RAW_BASE="file://$ROOT" + fresh n2 + run ps + # Documentation contract only: Helm access must bypass ingress and preserve + # the forwarded origin in the shared login + metrics commands. + grep -Fq 'kubectl port-forward -n skillhub service/skillhub-server 18080:8080' "$ROOT/docs/business-event-logs.md" || fail 'Helm metrics Service port-forward missing' + grep -Fq 'BASE=http://127.0.0.1:18080' "$ROOT/docs/business-event-logs.md" || fail 'Helm forwarded login/query origin missing' + grep -Fq 'BASE=${BASE:-http://127.0.0.1:8080}' "$ROOT/docs/business-event-logs.md" || fail 'shared query overwrites Helm forwarded origin' + grep -Fq '"$BASE/api/v1/auth/local/login"' "$ROOT/docs/business-event-logs.md" || fail 'login origin differs' + grep -Fq '"$BASE/actuator/metrics/skillhub.business.logs.enqueued"' "$ROOT/docs/business-event-logs.md" || fail 'metrics origin differs' + printf 'PASS N2: Helm metrics Service port-forward and same-origin admin query documented (contract, not live Kubernetes)\n' +} + +case "${1:-all}" in + b1|b2|n1|n2) "$1" ;; + all) b1; b2; n1; n2 ;; + *) fail 'expected b1, b2, n1, n2 or all' ;; +esac From b077a56abd5f1340f798afa306515eecec712438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 3 Nov 2026 17:54:30 +0800 Subject: [PATCH 19/21] docs: record Repair 4a independent review acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 千乘妍 (Xiaoyaner) --- .../acceptance.md | 19 +++++++++++++++++++ .../add-otlp-business-event-logs/tasks.md | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/openspec/changes/add-otlp-business-event-logs/acceptance.md b/openspec/changes/add-otlp-business-event-logs/acceptance.md index e87eba0f5..38d6806c3 100644 --- a/openspec/changes/add-otlp-business-event-logs/acceptance.md +++ b/openspec/changes/add-otlp-business-event-logs/acceptance.md @@ -150,3 +150,22 @@ A real demo stop/start initially failed because the file exporter truncates its Contract checks are separate: the fake-Docker lifecycle script covers default off, existing receiver, optional Collector, repeated up, updates, stop/start, disable, permissions, secret retention, flat OSS paths, and both Make modes. Python deployment checks pass 6 cases, including rendered private-network/volume/metrics settings. Helm configuration contracts and strict lint pass; the supplied minimal Helm image command required adding Python 3 for the chart's pre-existing Python contract checks. Bash 3's pre-existing empty-array issue in the storage selector test was avoided by running that test in a Linux Bash container. No production data or credentials were used. `npm ci`, the VitePress build, weekly site generation/validation and `git diff --check` pass. No full backend rerun, browser/CLI E2E, production TLS/mTLS, live Helm install/upgrade, real external Java agent or durable downstream storage validation is claimed. Host dev verification intentionally used only PostgreSQL/Redis and PRIVATE admin publications rather than the full scanner/frontend stack. Versioned/flat retrieval was exercised through the same download function with a local file root because these new files are not published yet; OSS/release upload and PR publication remain with the owner. No push or PR mutation was performed. + +## Repair 4a (independent review) + +Starting tree: `c327ea2e`. Evidence filenames below are relative to the worker's `evidence-r4/repair-a/` directory. Each new regression was run RED before its implementation/documentation fix, then GREEN. The fake-Docker suite is registered in script CI; the Helm access case is a documentation contract, not a live Kubernetes test. + +| Finding | Fix and executed regression | RED → GREEN evidence | +| --- | --- | --- | +| B1: image version changed file ref | Remove the global fallback; restore the entire lifecycle-argument function exactly to `2a64d152`. An existing analytics overlay plus `ps --version v0.2.25` records raw URLs under `main`, or `x` with `SKILLHUB_REF=x`. An env ref equal to the image version is retained in generated lifecycle commands. Guide and zh/en site explicitly pin `--ref --version ` together | `b1-red.log`: both refs incorrectly use `v0.2.25`, lifecycle omits ref, exit 1. `b1-green.log`: correct main/x URLs and lifecycle ref, exit 0 | +| B2: disable discarded attachment intent | Disable changes collection only, retaining the private-network flag. Private-receiver and optional-Collector enable → disable → enable both recreate server with the network overlay and retain the saved endpoint/network. Public HTTPS works without a private network | `b2-red.log`: private and Collector attachment failures, exit 1. `b2-green.log`: all three cases pass, exit 0 | +| N1: first Collector network option ignored | Seed the independent env from `--logs-network` only at first creation. A later conflicting CLI network leaves the existing env byte-for-byte unchanged and uses its saved network | `n1-red.log`: default network instead of supplied name, exit 1. `n1-green.log`: supplied network plus unchanged existing env, exit 0 | +| N2: Helm metrics access | Document server-Service port-forward, then admin login and metrics queries on that forwarded origin; the shared query preserves the selected BASE. No ingress changes | `n2-red.log`: missing Service port-forward, exit 1. `n2-green.log`: same-origin documentation contract passes, exit 0 | + +Real Docker used the authorized isolated `release-test` context and a fresh `server/Dockerfile` image build. Actual admin PRIVATE publication/download requests returned 200 in enabled, disabled and re-enabled states. The plain private receiver had 2 records after enable, still 2 after disable, and 4 after enable without repeating network/endpoint flags. Inspect confirmed that the disabled server remained attached with collection false; both disabled business facts were absent and the receiver request count did not change. The optional Collector path was also run: first-creation CLI network seeded, existing env preserved, downstream record counts 6 → 6 → 8 across enable → disable → enable. Each enabled assertion matched publication/download for that newly created skill, not an old record. Evidence: `real-lifecycle.log`, the scenario `*-received.json` files, `docker-build.log`, `image-inspect.log`. + +Real `ps --version trial` without `--ref` succeeded against the `file://` source with an existing analytics overlay, both with the default ref and `SKILLHUB_REF=x`. The URL-recording fake-fetch regression separately proves ref selection without a raw-base override. New remote tag/OSS availability is still not claimed. + +All four `scripts/tests/runtime-*.sh` suites passed. The requested Python deployment command passed 6 cases without skips. Helm contracts (with `apk add -q bash python3`) and strict lint passed, with 1 chart and 0 failures. Release validation and workflow-security suites, VitePress build, weekly site generation/validation, shell syntax, exact-function comparison, privacy scan and range diff checks passed. Logs: `runtime-*-final.log`, `python-contracts.log`, `helm-contracts.log`, `validate-release-config.log`, `workflow-security.log`, `docs-build.log`, `docs-weekly.log`, `source-audit.log`. + +Cleanup read-back confirmed owned containers removed, the isolated daemon's image/volume/network sets equal to their pre-run baselines, the local relay closed, and `release-test-dind` exited (`cleanup.log`, `cleanup-summary.json`). Shared builder cache was not pruned. No backend source changed, so Maven test suites were not rerun; the Dockerfile's normal packaging build skipped tests. No live Kubernetes/port-forward execution, new browser/CLI E2E, production TLS/authenticated storage, public release upload, push or PR mutation is claimed. Earlier broad-suite limits remain historical and unchanged. diff --git a/openspec/changes/add-otlp-business-event-logs/tasks.md b/openspec/changes/add-otlp-business-event-logs/tasks.md index b6bf4afea..36047298a 100644 --- a/openspec/changes/add-otlp-business-event-logs/tasks.md +++ b/openspec/changes/add-otlp-business-event-logs/tasks.md @@ -61,3 +61,11 @@ Repair 2 exact commands/counts/evidence and proposed fixup are appended to the w - [x] Verify real clean retains deployment files byte-for-byte at mode 600; remove the identified test containers, project volumes/networks and test images, preserve pre-existing images, stop the authorized dind and local relay, and verify owned ports are closed. Repair 4 real runs, template-only checks, intermediate fixture failures and remaining deployment limits are recorded in acceptance.md and the worker report. Commits remain local; publication is left to the owner. + +## Repair 4a (independent review) + +- [x] Add and execute RED/GREEN regressions for independent file-ref selection with an existing analytics overlay, remembered private/Collector attachment across disable/enable, first-created Collector network seeding without rewriting an existing env, and the Helm same-origin metrics access documentation contract. +- [x] Remove version-to-ref fallback; restore the base lifecycle function; preserve attachment intent independently of collection; seed only a newly created independent env; document both pinned arguments and Helm Service port-forward without ingress changes. +- [x] Rerun every runtime shell suite, six Python deployment cases, Helm contracts/strict lint with Python installed, release validation, workflow-security, docs/weekly builds, source/privacy checks and range whitespace checks. +- [x] Build the server image and run real private-receiver and optional-Collector disable/enable cycles, verify new publication/download facts and disabled zero-export behavior while attached, and exercise real ps with version but no ref against the file source. +- [x] Remove this round's Docker resources, verify baseline image/volume/network sets, close the relay and stop the isolated daemon; record unchanged backend/Kubernetes/remote-publication limits in acceptance and the worker report. From a4d2bbe462a08e2f7bba31fba7a1e6ebeddf4ed9 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:39:54 +0800 Subject: [PATCH 20/21] docs(observability): remove trailing empty line in receiver example Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/examples/business-event-logs/compose.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/examples/business-event-logs/compose.yaml b/docs/examples/business-event-logs/compose.yaml index 5e3be31b6..a5bbf461d 100644 --- a/docs/examples/business-event-logs/compose.yaml +++ b/docs/examples/business-event-logs/compose.yaml @@ -20,4 +20,3 @@ networks: business-logs: external: true name: ${SKILLHUB_BUSINESS_LOGS_NETWORK:-skillhub-business-logs-demo} - From 89066c698848fe99084e0338cf5fb32bfe8fd0ba Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:24:35 +0800 Subject: [PATCH 21/21] test(observability): isolate raw OTel contexts and assert trace coexistence Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- server/skillhub-app/pom.xml | 17 ++++++- .../business/BusinessLogsCollectorTest.java | 1 + .../business/BusinessLogsHttpTest.java | 1 + .../business/BusinessLogsPipelineTest.java | 1 + .../BusinessLogsProducerPostgresTest.java | 1 + .../BusinessLogsTracingCoexistenceTest.java | 47 +++++++++++++++++++ .../business/BusinessLogsTransactionTest.java | 1 + 7 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTracingCoexistenceTest.java diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index 224e42d11..428982a51 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -175,7 +175,22 @@ default-test - process-global-diagnostics + process-global-diagnostics,process-global-otel-context + + + + + isolated-otel-context + test + + process-global-otel-context + + **/BusinessLogs*Test.java + + false + 1 + false diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java index dcf5f14e2..898e77f69 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsCollectorTest.java @@ -32,6 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat; /** Actual production configuration sends protobuf over HTTP to a pinned real Collector/file sink. */ @Testcontainers +@org.junit.jupiter.api.Tag("process-global-otel-context") class BusinessLogsCollectorTest { @Container static final GenericContainer COLLECTOR = new GenericContainer<>("otel/opentelemetry-collector-contrib:0.111.0") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java index e7b31b3cc..ee1580d07 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsHttpTest.java @@ -21,6 +21,7 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.assertThat; +@org.junit.jupiter.api.Tag("process-global-otel-context") class BusinessLogsHttpTest { @Test void truncatedErrorBodyCannotPermanentlyExhaustAdmission() throws Exception { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsPipelineTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsPipelineTest.java index f0f94df33..cdba9688e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsPipelineTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsPipelineTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +@org.junit.jupiter.api.Tag("process-global-otel-context") class BusinessLogsPipelineTest { @ParameterizedTest @ValueSource(booleans = {true, false}) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java index 22d182dc8..e4cdb2503 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsProducerPostgresTest.java @@ -64,6 +64,7 @@ import static org.mockito.Mockito.when; SkillDownloadService.class, SkillSlugResolutionService.class, ReviewService.class, PromotionService.class, ReviewPermissionChecker.class, BusinessLogsProducerPostgresTest.Configuration.class}) @Transactional(propagation = Propagation.NOT_SUPPORTED) +@org.junit.jupiter.api.Tag("process-global-otel-context") class BusinessLogsProducerPostgresTest { @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTracingCoexistenceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTracingCoexistenceTest.java new file mode 100644 index 000000000..d786bbd01 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTracingCoexistenceTest.java @@ -0,0 +1,47 @@ +package com.iflytek.skillhub.observability.business; + +import com.iflytek.skillhub.observability.tracing.SkillHubTracingConfiguration; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.tracing.Tracer; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Production configurations must coexist in the same startup, before any raw test context. */ +@Tag("process-global-otel-context") +class BusinessLogsTracingCoexistenceTest { + @Configuration(proxyBeanMethods = false) + @EnableAutoConfiguration + @Import({SkillHubTracingConfiguration.class, BusinessLogsConfiguration.class}) + static class Application {} + + @Test + void enablingBusinessLogsPreservesTraceAndSpanCorrelation() { + new ApplicationContextRunner().withUserConfiguration(Application.class) + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withPropertyValues("spring.flyway.enabled=false", "spring.jpa.hibernate.ddl-auto=none", + "skillhub.observability.tracing-mode=otel-sdk", + "management.tracing.sampling.probability=1", + "skillhub.observability.business-logs.enabled=true", + "skillhub.observability.business-logs.endpoint=http://127.0.0.1:4318/v1/logs") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(BusinessLogsPipeline.class); + Tracer tracer = context.getBean(Tracer.class); + var span = tracer.nextSpan().name("business-logs-coexistence").start(); + try (var ignored = tracer.withSpan(span)) { + assertThat(MDC.get("traceId")).isEqualTo(span.context().traceId()).hasSize(32); + assertThat(MDC.get("spanId")).isEqualTo(span.context().spanId()).hasSize(16); + } finally { + span.end(); + MDC.clear(); + } + }); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java index c8f800de7..6e19a6744 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/business/BusinessLogsTransactionTest.java @@ -47,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; @Testcontainers +@org.junit.jupiter.api.Tag("process-global-otel-context") class BusinessLogsTransactionTest { @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")