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(); } + } +}