diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index dd788e9..d966685 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -13,7 +13,7 @@ permissions: jobs: maven-policy: name: Shared Maven policy - uses: HauntedMC/HauntedPlatform/.github/workflows/maven-ci.yml@v1.2.0 + uses: HauntedMC/HauntedPlatform/.github/workflows/maven-ci.yml@v1.3.0 with: maven-command: ./mvnw -U -B -ntp -DskipTests verify secrets: diff --git a/docs/OBSERVATION.md b/docs/OBSERVATION.md new file mode 100644 index 0000000..ee1f8c1 --- /dev/null +++ b/docs/OBSERVATION.md @@ -0,0 +1,103 @@ +# Lifecycle operation observation + +FeatureFramework exposes an optional, vendor-neutral observation SPI for measuring or tracing meaningful host and feature lifecycle operations without depending on OpenTelemetry, HauntedObservability, or another telemetry implementation. + +## Attach an observer + +Paper and Velocity hosts accept an observer during construction: + +```java +PaperFeatureHost.builder(plugin, ApiRoot.class, features) + .observer(observer) + .build(); +``` + +```java +VelocityFeatureHost.builder(plugin, proxy, logger, dataDirectory, ApiRoot.class, features) + .observer(observer) + .build(); +``` + +The observer belongs to that host instance. There is no static observer registry, service locator, or global registration. Existing builders remain source-compatible because the default is `FeatureFrameworkObserver.noop()`. + +## Public contract + +The dependency-free API consists of: + +- `FeatureFrameworkObserver`, which starts one observation; +- `FeatureFrameworkObservation`, which optionally activates adapter-specific context and receives terminal completion; +- `FeatureFrameworkObservationScope`, which propagates adapter context while FeatureFramework executes the operation; +- `FeatureFrameworkOperationContext`, which contains only the bounded operation kind and optional framework-owned `FeatureId`; +- `FeatureFrameworkOperationKind`, the stable operation vocabulary; +- `FeatureFrameworkOperationOutcome`, a bounded terminal classification. + +Runtime exceptions from observer start, scope activation, completion, and scope cleanup are isolated from FeatureFramework behavior. Java `Error`s are not swallowed. An observability adapter must be non-blocking; FeatureFramework never requires one to be present. + +With the default no-op observer, FeatureFramework executes lifecycle work without constructing an observation context or running terminal observation classification. A custom observer necessarily receives the bounded context so it can decide whether to observe an operation; if it filters that operation by returning `FeatureFrameworkObservation.noop()`, FeatureFramework then skips scope activation, terminal classification, and completion for that operation. + +## Operation vocabulary + +The initial contract observes: + +- `HOST_START` +- `HOST_STOP` +- `FEATURE_LOAD` +- `FEATURE_ENABLE` +- `FEATURE_DISABLE` +- `FEATURE_RECREATE` +- `FEATURE_SOFT_RELOAD` +- `GRAPH_RELOAD` +- `FILE_RESET` + +`FEATURE_LOAD` is emitted from the one actual startup path in `FeatureInstanceController`. It therefore covers initial startup, explicit enable, dependency-driven startup, recreation, graph reload, and reset-driven restart. Higher-level operations are emitted only at their public serialized host boundary, so internal reload recursion does not create duplicate `FEATURE_ENABLE`/`FEATURE_DISABLE` operations. + +A normal recreation can therefore look like: + +```text +FEATURE_RECREATE lottery +└── FEATURE_LOAD lottery +``` + +A graph reload can contain multiple nested `FEATURE_LOAD` operations without pretending that each internal reconciliation step was a separately requested enable/recreate command. + +## Outcomes + +The stable outcomes are: + +- `SUCCESS` — requested lifecycle work completed; +- `NO_CHANGE` — the requested state already held, such as disabling an already-unloaded feature; +- `SKIPPED` — a bounded precondition prevented work, such as a missing feature/dependency or unavailable reset target; +- `FAILURE` — lifecycle work was attempted and failed. + +Where FeatureFramework owns a concrete `Throwable`, it is supplied separately as diagnostic context. Exception messages must not be turned into metric labels. + +## Metadata and cardinality boundary + +`FeatureFrameworkOperationContext` contains exactly: + +- a `FeatureFrameworkOperationKind`; and +- a `FeatureId` only when the operation is feature-scoped. + +It deliberately does **not** expose configuration values, file paths, plugin objects, dependency lists, command arguments, player identifiers, server addresses, database information, SQL/query text, arbitrary caller strings, or arbitrary attribute maps. + +The operation kind and FeatureId are the intended bounded dimensions for metrics. Adapters may attach richer failure detail to traces/logs according to their own privacy policy, but not as metric labels. + +## Layering with DataRegistry and DataProvider + +The three neutral SPIs represent different ownership layers: + +```text +FeatureFramework lifecycle operation +├── DataRegistry semantic/domain operation +└── DataProvider storage operation +``` + +FeatureFramework answers **which feature or host lifecycle operation is running**. DataRegistry answers **which registry/domain operation is running**. DataProvider answers **which backend/storage operation is running**. HauntedObservability should preserve this hierarchy rather than duplicating storage or registry instrumentation inside FeatureFramework. + +FeatureFramework does not register DataRegistry or DataProvider observers itself. The application composition root attaches all three observers to their respective runtimes. + +## Scope boundary + +This release intentionally does not observe every configuration read, dependency check, repository call, resource cleanup callback, event/listener invocation, or preview operation. `previewFileReset(...)` remains a read-only operation and is not observed. + +FeatureFramework itself has no OpenTelemetry or HauntedObservability dependency. The later HauntedObservability FeatureFramework integration will implement this neutral SPI. diff --git a/docs/README.md b/docs/README.md index 018a08a..a004d84 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ If FeatureFramework is new to you, read the [feature mental model](concepts/FEAT - [Architecture](ARCHITECTURE.md) - [Threading](THREADING.md) +- [Lifecycle operation observation](OBSERVATION.md) - [Version migration notes](MIGRATION.md) - [Coordinated release process](RELEASE.md) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 3c5a3b2..1b228e3 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -6,9 +6,10 @@ versions separately. The reactor publishes `featureframework-theme-api` with the other framework artifacts. Publish FeatureFramework before any separately versioned theme adapter that targets the new API. -For the 1.6.0 boundary release, publish FeatureFramework first, then ProxyFeatures 3.6.0, then -ServerFeatures 3.6.0. The consumer releases carry the private contracts and platform implementations -removed from the public framework. +For the 1.7.0 observability boundary, HauntedPlatform 1.3.0, DataProvider 3.3.0, and DataRegistry 1.15.0 must already be +published. Publish FeatureFramework 1.7.0 before HauntedObservability 1.0.0. After HauntedObservability is published, +align the ecosystem through HauntedPlatform 1.4.0 before ServerFeatures and ProxyFeatures adopt the observability runtime. +FeatureFramework remains vendor-neutral and does not depend on HauntedObservability. ## 1. Prepare diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservation.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservation.java new file mode 100644 index 0000000..48fbf4d --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservation.java @@ -0,0 +1,30 @@ +package nl.hauntedmc.featureframework.api.observation; + +/** One in-flight FeatureFramework operation returned by a {@link FeatureFrameworkObserver}. */ +public interface FeatureFrameworkObservation { + + /** + * Activates adapter-specific context while FeatureFramework executes work for this observation. + * Implementations should return a non-null scope that is closed on the same thread. + */ + default FeatureFrameworkObservationScope openScope() { + return FeatureFrameworkObservationScope.noop(); + } + + /** Completes the observation with one stable outcome and optional diagnostic failure. */ + void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure); + + /** Returns the reusable no-op observation. */ + static FeatureFrameworkObservation noop() { + return NoopFeatureFrameworkObservation.INSTANCE; + } +} + +enum NoopFeatureFrameworkObservation implements FeatureFrameworkObservation { + INSTANCE; + + @Override + public void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure) { + // Intentionally empty. + } +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservationScope.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservationScope.java new file mode 100644 index 0000000..ed80f32 --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservationScope.java @@ -0,0 +1,23 @@ +package nl.hauntedmc.featureframework.api.observation; + +/** Adapter-specific context scope activated while FeatureFramework executes observed work. */ +@FunctionalInterface +public interface FeatureFrameworkObservationScope extends AutoCloseable { + + @Override + void close(); + + /** Returns the reusable no-op scope. */ + static FeatureFrameworkObservationScope noop() { + return NoopFeatureFrameworkObservationScope.INSTANCE; + } +} + +enum NoopFeatureFrameworkObservationScope implements FeatureFrameworkObservationScope { + INSTANCE; + + @Override + public void close() { + // Intentionally empty. + } +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObserver.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObserver.java new file mode 100644 index 0000000..3a5e715 --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObserver.java @@ -0,0 +1,26 @@ +package nl.hauntedmc.featureframework.api.observation; + +/** Vendor-neutral observer for meaningful FeatureFramework lifecycle and runtime operations. */ +@FunctionalInterface +public interface FeatureFrameworkObserver { + + /** + * Starts one observation. Runtime exceptions from observation callbacks are isolated by + * FeatureFramework; Java {@link Error}s are not swallowed. + */ + FeatureFrameworkObservation start(FeatureFrameworkOperationContext context); + + /** Returns the reusable no-op observer. */ + static FeatureFrameworkObserver noop() { + return NoopFeatureFrameworkObserver.INSTANCE; + } +} + +enum NoopFeatureFrameworkObserver implements FeatureFrameworkObserver { + INSTANCE; + + @Override + public FeatureFrameworkObservation start(FeatureFrameworkOperationContext context) { + return FeatureFrameworkObservation.noop(); + } +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationContext.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationContext.java new file mode 100644 index 0000000..912f32e --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationContext.java @@ -0,0 +1,44 @@ +package nl.hauntedmc.featureframework.api.observation; + +import nl.hauntedmc.featureframework.api.feature.FeatureId; + +import java.util.Objects; +import java.util.Optional; + +/** + * Stable, payload-free metadata for one FeatureFramework operation. + * + *

The context deliberately contains only a bounded operation kind and, for feature-scoped + * operations, the framework-owned {@link FeatureId}. Configuration values, file paths, plugin + * objects, dependency lists, command input, player data, and arbitrary caller strings do not belong here.

+ */ +public record FeatureFrameworkOperationContext( + FeatureFrameworkOperationKind operation, + Optional featureId +) { + + public FeatureFrameworkOperationContext { + Objects.requireNonNull(operation, "operation"); + featureId = featureId == null ? Optional.empty() : featureId; + if (operation.featureScoped() != featureId.isPresent()) { + throw new IllegalArgumentException( + operation.featureScoped() + ? "Feature-scoped operations require a FeatureId." + : "Host-scoped operations must not include a FeatureId." + ); + } + } + + /** Creates a host-scoped context. */ + public static FeatureFrameworkOperationContext host(FeatureFrameworkOperationKind operation) { + return new FeatureFrameworkOperationContext(operation, Optional.empty()); + } + + /** Creates a feature-scoped context. */ + public static FeatureFrameworkOperationContext feature( + FeatureFrameworkOperationKind operation, + FeatureId featureId + ) { + return new FeatureFrameworkOperationContext(operation, Optional.of(Objects.requireNonNull(featureId, "featureId"))); + } +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationKind.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationKind.java new file mode 100644 index 0000000..7deb03f --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationKind.java @@ -0,0 +1,25 @@ +package nl.hauntedmc.featureframework.api.observation; + +/** Stable, low-cardinality operation kinds exposed by FeatureFramework observation. */ +public enum FeatureFrameworkOperationKind { + HOST_START(false), + HOST_STOP(false), + FEATURE_LOAD(true), + FEATURE_ENABLE(true), + FEATURE_DISABLE(true), + FEATURE_RECREATE(true), + FEATURE_SOFT_RELOAD(true), + GRAPH_RELOAD(false), + FILE_RESET(true); + + private final boolean featureScoped; + + FeatureFrameworkOperationKind(boolean featureScoped) { + this.featureScoped = featureScoped; + } + + /** Returns whether this operation must identify one FeatureFramework feature. */ + public boolean featureScoped() { + return featureScoped; + } +} diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationOutcome.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationOutcome.java new file mode 100644 index 0000000..2c06ec0 --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationOutcome.java @@ -0,0 +1,14 @@ +package nl.hauntedmc.featureframework.api.observation; + +/** Stable, low-cardinality terminal outcome for one observed framework operation. */ +public enum FeatureFrameworkOperationOutcome { + SUCCESS, + NO_CHANGE, + SKIPPED, + FAILURE; + + /** Returns whether this outcome represents unsuccessful completion. */ + public boolean isFailure() { + return this == FAILURE; + } +} diff --git a/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java b/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java new file mode 100644 index 0000000..e79474c --- /dev/null +++ b/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java @@ -0,0 +1,31 @@ +package nl.hauntedmc.featureframework.api; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class ObservationBoundaryTest { + + @Test + void publicApiHasNoTelemetryImplementationDependency() throws IOException { + Path sources = Path.of("src", "main", "java"); + try (var files = Files.walk(sources)) { + for (Path file : files.filter(path -> path.toString().endsWith(".java")).toList()) { + String source = Files.readString(file); + assertFalse(source.contains("io.opentelemetry"), () -> file + " contains OpenTelemetry coupling"); + assertFalse( + source.contains("nl.hauntedmc.observability"), + () -> file + " contains HauntedObservability coupling" + ); + assertFalse( + source.contains("hauntedobservability"), + () -> file + " contains legacy HauntedObservability coupling" + ); + } + } + } +} diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java new file mode 100644 index 0000000..276e183 --- /dev/null +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java @@ -0,0 +1,190 @@ +package nl.hauntedmc.featureframework.host; + +import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservation; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationContext; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.function.Supplier; + +/** Runtime-local, single-observer dispatcher that keeps instrumentation fail-open. */ +final class FeatureFrameworkObservations { + + private static final Operation NOOP_OPERATION = new Operation(FeatureFrameworkObservation.noop()); + + private final FeatureFrameworkObserver observer; + private final boolean enabled; + + FeatureFrameworkObservations(FeatureFrameworkObserver observer) { + this.observer = Objects.requireNonNull(observer, "observer"); + this.enabled = observer != FeatureFrameworkObserver.noop(); + } + + Operation start(FeatureFrameworkOperationKind kind) { + if (!enabled) { + return NOOP_OPERATION; + } + return start(FeatureFrameworkOperationContext.host(kind)); + } + + Operation start(FeatureFrameworkOperationKind kind, FeatureId featureId) { + if (!enabled) { + return NOOP_OPERATION; + } + return start(FeatureFrameworkOperationContext.feature(kind, featureId)); + } + + T observe( + FeatureFrameworkOperationKind kind, + Supplier work, + Function outcome, + Function failure + ) { + Objects.requireNonNull(work, "work"); + if (!enabled) { + return work.get(); + } + return observe(start(kind), work, outcome, failure); + } + + T observe( + FeatureFrameworkOperationKind kind, + FeatureId featureId, + Supplier work, + Function outcome, + Function failure + ) { + Objects.requireNonNull(work, "work"); + if (!enabled) { + return work.get(); + } + return observe(start(kind, featureId), work, outcome, failure); + } + + private T observe( + Operation operation, + Supplier work, + Function outcome, + Function failure + ) { + if (operation.isNoop()) { + return work.get(); + } + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(failure, "failure"); + FeatureFrameworkObservationScope scope = operation.openScope(); + try { + try { + T result = work.get(); + completeFromResult(operation, result, outcome, failure); + return result; + } catch (Throwable throwable) { + operation.complete(FeatureFrameworkOperationOutcome.FAILURE, throwable); + return throwUnchecked(throwable); + } + } finally { + scope.close(); + } + } + + private static void completeFromResult( + Operation operation, + T result, + Function outcome, + Function failure + ) { + try { + operation.complete( + Objects.requireNonNull(outcome.apply(result), "observation outcome"), + failure.apply(result) + ); + } catch (RuntimeException classificationFailure) { + operation.complete(FeatureFrameworkOperationOutcome.FAILURE, classificationFailure); + } + } + + private Operation start(FeatureFrameworkOperationContext context) { + try { + FeatureFrameworkObservation observation = observer.start(context); + if (observation == null || observation == FeatureFrameworkObservation.noop()) { + return NOOP_OPERATION; + } + return new Operation(observation); + } catch (RuntimeException ignored) { + return NOOP_OPERATION; + } + } + + static final class Operation { + private final FeatureFrameworkObservation observation; + private final AtomicBoolean completed; + + private Operation(FeatureFrameworkObservation observation) { + this.observation = observation; + this.completed = observation == FeatureFrameworkObservation.noop() ? null : new AtomicBoolean(); + } + + boolean isNoop() { + return completed == null; + } + + FeatureFrameworkObservationScope openScope() { + if (isNoop()) { + return FeatureFrameworkObservationScope.noop(); + } + try { + FeatureFrameworkObservationScope scope = observation.openScope(); + if (scope == null || scope == FeatureFrameworkObservationScope.noop()) { + return FeatureFrameworkObservationScope.noop(); + } + return new SafeScope(scope); + } catch (RuntimeException ignored) { + return FeatureFrameworkObservationScope.noop(); + } + } + + void complete(FeatureFrameworkOperationOutcome outcome, Throwable failure) { + Objects.requireNonNull(outcome, "outcome"); + if (isNoop() || !completed.compareAndSet(false, true)) { + return; + } + try { + observation.completed(outcome, failure); + } catch (RuntimeException ignored) { + // Instrumentation must never replace the framework operation outcome. + } + } + } + + private static final class SafeScope implements FeatureFrameworkObservationScope { + private final FeatureFrameworkObservationScope delegate; + private final AtomicBoolean closed = new AtomicBoolean(); + + private SafeScope(FeatureFrameworkObservationScope delegate) { + this.delegate = delegate; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + try { + delegate.close(); + } catch (RuntimeException ignored) { + // Context cleanup must never alter FeatureFramework behavior. + } + } + } + + @SuppressWarnings("unchecked") + private static T throwUnchecked(Throwable failure) throws E { + throw (E) failure; + } +} diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java index 630b588..df7102a 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java @@ -4,40 +4,44 @@ import nl.hauntedmc.featureframework.api.RuntimeState; import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome; import nl.hauntedmc.featureframework.api.service.CapabilityRegistry; import nl.hauntedmc.featureframework.config.FeatureConfigurationRoot; import nl.hauntedmc.featureframework.feature.LifecycleFeature; -import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition; +import nl.hauntedmc.featureframework.feature.stateful.SnapshotState; import nl.hauntedmc.featureframework.loader.FeatureLoadOrderResolver; +import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition; import nl.hauntedmc.featureframework.operation.FeatureOperationCoordinator; import nl.hauntedmc.featureframework.operation.disable.FeatureDisableResponse; import nl.hauntedmc.featureframework.operation.enable.FeatureEnableResponse; import nl.hauntedmc.featureframework.operation.reload.FeatureGraphReloadResult; import nl.hauntedmc.featureframework.operation.reload.FeatureGraphReloader; import nl.hauntedmc.featureframework.operation.reload.FeatureReloadResponse; -import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetPreview; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetRequest; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetResponse; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetResult; import nl.hauntedmc.featureframework.operation.reset.FeatureResetRollbackOutcome; import nl.hauntedmc.featureframework.operation.reset.FeatureResetRuntimeOutcome; +import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse; import nl.hauntedmc.featureframework.runtime.FeatureRuntime; import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry; -import nl.hauntedmc.featureframework.feature.stateful.SnapshotState; import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.Map; import java.util.Set; import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; -import java.util.function.Consumer; /** * Complete platform-neutral host for a collection of managed features. @@ -62,6 +66,7 @@ final class FeatureHost, C extends FeatureHostC private final FeatureInventory inventory; private final FeatureInstanceController controller; private final FeatureFileResetStorage resetStorage; + private final FeatureFrameworkObservations observations; private boolean startAttempted; private FeatureHost(Builder builder) { @@ -80,10 +85,12 @@ private FeatureHost(Builder builder) { ? configuration::reloadConfig : builder.reloadHostResources; logger = Objects.requireNonNull(builder.logger, "logger"); + observations = new FeatureFrameworkObservations(builder.observer); resetStorage = new FeatureFileResetStorage(configuration.files(), logger); inventory = new FeatureInventory<>( capabilityNamespace, runtime, configuration, collection, pluginAvailable, logger); - controller = new FeatureInstanceController<>(inventory, runtime, configuration, contextFactory, logger); + controller = new FeatureInstanceController<>( + inventory, runtime, configuration, contextFactory, logger, observations); } public static , C extends FeatureHostContext> @@ -104,6 +111,18 @@ public void start() { } private synchronized void startLocked() { + observations.observe( + FeatureFrameworkOperationKind.HOST_START, + () -> { + startLockedUnobserved(); + return null; + }, + ignored -> FeatureFrameworkOperationOutcome.SUCCESS, + ignored -> null + ); + } + + private void startLockedUnobserved() { if (startAttempted) throw new IllegalStateException(hostName + " has already been started"); startAttempted = true; runtime.markStarting(); @@ -129,11 +148,18 @@ private void initializeFeatures() { } public FeatureEnableResponse enable(FeatureId id) { - return enableLockedId(Objects.requireNonNull(id, "id").value()); + FeatureId featureId = Objects.requireNonNull(id, "id"); + return runtime.lifecycle().callExclusive(() -> observations.observe( + FeatureFrameworkOperationKind.FEATURE_ENABLE, + featureId, + () -> enableLockedId(featureId.value()), + FeatureHost::enableOutcome, + ignored -> null + )); } private FeatureEnableResponse enableLockedId(String featureName) { - return runtime.lifecycle().callExclusive(() -> FeatureOperationCoordinator.enable( + return FeatureOperationCoordinator.enable( featureName, inventory::resolveFeatureKey, key -> inventory.registry().getAvailableFeature(key) != null, @@ -143,11 +169,18 @@ private FeatureEnableResponse enableLockedId(String featureName) { this::persistEnabled, controller::loadFeature, afterGraphMutation - )); + ); } public FeatureDisableResponse disable(FeatureId id) { - return runtime.lifecycle().callExclusive(() -> disableFeatureLocked(Objects.requireNonNull(id, "id").value())); + FeatureId featureId = Objects.requireNonNull(id, "id"); + return runtime.lifecycle().callExclusive(() -> observations.observe( + FeatureFrameworkOperationKind.FEATURE_DISABLE, + featureId, + () -> disableFeatureLocked(featureId.value()), + FeatureHost::disableOutcome, + ignored -> null + )); } private FeatureDisableResponse disableFeatureLocked(String featureName) { @@ -165,27 +198,47 @@ private FeatureDisableResponse disableFeatureLocked(String featureName) { } public FeatureSoftReloadResponse softReload(FeatureId id) { - String featureName = Objects.requireNonNull(id, "id").value(); - return runtime.lifecycle().callExclusive(() -> FeatureOperationCoordinator.softReload( - featureName, - inventory::resolveFeatureKey, - inventory.registry()::isFeatureLoaded, - key -> { - F feature = controller.loadedFeature(key); - feature.context().prepare(feature); - return feature.applyConfiguration(); - }, - this::recreateLocked + FeatureId featureId = Objects.requireNonNull(id, "id"); + return runtime.lifecycle().callExclusive(() -> observations.observe( + FeatureFrameworkOperationKind.FEATURE_SOFT_RELOAD, + featureId, + () -> FeatureOperationCoordinator.softReload( + featureId.value(), + inventory::resolveFeatureKey, + inventory.registry()::isFeatureLoaded, + key -> { + F feature = controller.loadedFeature(key); + feature.context().prepare(feature); + return feature.applyConfiguration(); + }, + this::recreateLocked + ), + FeatureHost::softReloadOutcome, + ignored -> null )); } public FeatureReloadResponse recreate(FeatureId id) { - return runtime.lifecycle().callExclusive(() -> recreateLocked(Objects.requireNonNull(id, "id").value())); + FeatureId featureId = Objects.requireNonNull(id, "id"); + return runtime.lifecycle().callExclusive(() -> observations.observe( + FeatureFrameworkOperationKind.FEATURE_RECREATE, + featureId, + () -> recreateLocked(featureId.value()), + FeatureHost::reloadOutcome, + ignored -> null + )); } /** Reloads host configuration and transactionally reconciles every configured feature. */ public FeatureGraphReloadResult reloadGraph() { - return runtime.lifecycle().callExclusive(this::reloadLocked); + return runtime.lifecycle().callExclusive(() -> observations.observe( + FeatureFrameworkOperationKind.GRAPH_RELOAD, + this::reloadLocked, + result -> result.success() + ? FeatureFrameworkOperationOutcome.SUCCESS + : FeatureFrameworkOperationOutcome.FAILURE, + result -> result.failure().orElse(null) + )); } public FeatureFileResetPreview previewFileReset(FeatureId id, FeatureFileResetRequest request) { @@ -217,9 +270,15 @@ private FeatureFileResetPreview previewFileResetLocked(String requested, Feature } public FeatureFileResetResponse resetFiles(FeatureId id, FeatureFileResetRequest request) { - Objects.requireNonNull(id, "id"); + FeatureId featureId = Objects.requireNonNull(id, "id"); Objects.requireNonNull(request, "request"); - return runtime.lifecycle().callExclusive(() -> resetFilesLocked(id.value(), request)); + return runtime.lifecycle().callExclusive(() -> observations.observe( + FeatureFrameworkOperationKind.FILE_RESET, + featureId, + () -> resetFilesLocked(featureId.value(), request), + FeatureHost::resetOutcome, + response -> response.failure().orElse(null) + )); } boolean reloadFeatureLocalization(FeatureId id, Consumer reload) { @@ -446,11 +505,27 @@ public void stop() { } private synchronized void stopLocked() { - if (runtime.state() == RuntimeState.STOPPED) return; - runtime.markStopping(); - Throwable failure = unloadAll(); - runtime.markStopped(failure); - if (failure != null) logger.error(hostName + " shutdown completed with failures.", failure); + FeatureFrameworkObservations.Operation observation = observations.start(FeatureFrameworkOperationKind.HOST_STOP); + FeatureFrameworkObservationScope scope = observation.openScope(); + try { + if (runtime.state() == RuntimeState.STOPPED) { + observation.complete(FeatureFrameworkOperationOutcome.NO_CHANGE, null); + return; + } + runtime.markStopping(); + Throwable failure = unloadAll(); + runtime.markStopped(failure); + if (failure != null) logger.error(hostName + " shutdown completed with failures.", failure); + observation.complete( + failure == null ? FeatureFrameworkOperationOutcome.SUCCESS : FeatureFrameworkOperationOutcome.FAILURE, + failure + ); + } catch (Throwable failure) { + observation.complete(FeatureFrameworkOperationOutcome.FAILURE, failure); + throwUnchecked(failure); + } finally { + scope.close(); + } } private Throwable unloadAll() { @@ -516,6 +591,7 @@ public static final class Builder< private Runnable clearScopes = () -> { }; private Runnable reloadHostResources; private FrameworkLogger logger = FrameworkLogger.noop(); + private FeatureFrameworkObserver observer = FeatureFrameworkObserver.noop(); private Builder( String hostName, @@ -563,12 +639,59 @@ public Builder logger(FrameworkLogger value) { return this; } + public Builder observer(FeatureFrameworkObserver value) { + observer = Objects.requireNonNull(value, "observer"); + return this; + } public FeatureHost build() { return new FeatureHost<>(this); } } + private static FeatureFrameworkOperationOutcome enableOutcome(FeatureEnableResponse response) { + return switch (response.result()) { + case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; + case ALREADY_LOADED -> FeatureFrameworkOperationOutcome.NO_CHANGE; + case NOT_FOUND, MISSING_PLUGIN_DEPENDENCY, MISSING_FEATURE_DEPENDENCY -> + FeatureFrameworkOperationOutcome.SKIPPED; + case FAILED -> FeatureFrameworkOperationOutcome.FAILURE; + }; + } + + private static FeatureFrameworkOperationOutcome disableOutcome(FeatureDisableResponse response) { + return switch (response.result()) { + case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; + case NOT_LOADED -> FeatureFrameworkOperationOutcome.NO_CHANGE; + case FAILED -> FeatureFrameworkOperationOutcome.FAILURE; + }; + } + + private static FeatureFrameworkOperationOutcome reloadOutcome(FeatureReloadResponse response) { + return switch (response.result()) { + case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; + case NOT_LOADED -> FeatureFrameworkOperationOutcome.SKIPPED; + case FAILED -> FeatureFrameworkOperationOutcome.FAILURE; + }; + } + + private static FeatureFrameworkOperationOutcome softReloadOutcome(FeatureSoftReloadResponse response) { + return switch (response.result()) { + case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; + case NOT_LOADED -> FeatureFrameworkOperationOutcome.SKIPPED; + case FAILED -> FeatureFrameworkOperationOutcome.FAILURE; + }; + } + + private static FeatureFrameworkOperationOutcome resetOutcome(FeatureFileResetResponse response) { + return switch (response.result()) { + case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS; + case NOT_FOUND, HOST_UNAVAILABLE, UNSAFE_TARGET -> FeatureFrameworkOperationOutcome.SKIPPED; + case QUIESCE_FAILED, BACKUP_FAILED, REGENERATION_FAILED, RESTART_FAILED, ROLLBACK_FAILED -> + FeatureFrameworkOperationOutcome.FAILURE; + }; + } + private static String requireText(String value, String field) { String clean = Objects.requireNonNull(value, field).trim(); if (clean.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java index 902fdfd..326bd86 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java @@ -1,8 +1,9 @@ package nl.hauntedmc.featureframework.host; -import nl.hauntedmc.featureframework.api.feature.FeatureId; -import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.RuntimeState; +import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; +import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver; import nl.hauntedmc.featureframework.api.service.CapabilityRegistry; import nl.hauntedmc.featureframework.config.FeatureConfigurationRoot; import nl.hauntedmc.featureframework.feature.LifecycleFeature; @@ -13,16 +14,16 @@ import nl.hauntedmc.featureframework.operation.enable.FeatureEnableResponse; import nl.hauntedmc.featureframework.operation.reload.FeatureGraphReloadResult; import nl.hauntedmc.featureframework.operation.reload.FeatureReloadResponse; -import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetPreview; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetRequest; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetResponse; +import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse; import nl.hauntedmc.featureframework.runtime.FeatureRuntime; import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry; import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; -import java.util.Objects; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletionStage; import java.util.function.Function; @@ -63,6 +64,46 @@ public FeatureHostComposition( Runnable reloadLocalization, Runnable afterHostResourcesReload, FrameworkLogger logger + ) { + this( + hostName, + version, + capabilityNamespace, + runtime, + configuration, + features, + configFactory, + localizationFactory, + loggerFactory, + resourcesFactory, + contextAssembler, + pluginAvailable, + afterGraphMutation, + reloadLocalization, + afterHostResourcesReload, + logger, + FeatureFrameworkObserver.noop() + ); + } + + public FeatureHostComposition( + String hostName, + V version, + String capabilityNamespace, + FeatureRuntime runtime, + FeatureConfigurationRoot configuration, + FeatureCollection features, + Function configFactory, + Function localizationFactory, + Function loggerFactory, + Function, ? extends R> resourcesFactory, + FeatureScopeFactory.ContextAssembler contextAssembler, + Predicate pluginAvailable, + Runnable afterGraphMutation, + Runnable reloadLocalization, + Runnable afterHostResourcesReload, + FrameworkLogger logger, + FeatureFrameworkObserver observer ) { Objects.requireNonNull(runtime, "runtime"); Objects.requireNonNull(configuration, "configuration"); @@ -80,6 +121,7 @@ public FeatureHostComposition( .clearScopes(scopes::clear) .reloadHostResources(reloadResources) .logger(Objects.requireNonNull(logger, "logger")) + .observer(Objects.requireNonNull(observer, "observer")) .build(); } diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java index 9244a3d..f56168c 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java @@ -2,24 +2,27 @@ import nl.hauntedmc.featureframework.api.feature.FeatureId; import nl.hauntedmc.featureframework.api.feature.FeatureState; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome; import nl.hauntedmc.featureframework.config.FeatureConfigurationRoot; +import nl.hauntedmc.featureframework.config.FeatureStoragePaths; import nl.hauntedmc.featureframework.feature.LifecycleFeature; import nl.hauntedmc.featureframework.feature.stateful.FeatureReloadState; import nl.hauntedmc.featureframework.feature.stateful.SnapshotState; import nl.hauntedmc.featureframework.loader.FeatureDependencyManager; -import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition; import nl.hauntedmc.featureframework.loader.FeatureGraphLifecycle; import nl.hauntedmc.featureframework.loader.FeatureGraphReloadTransaction; import nl.hauntedmc.featureframework.loader.FeatureRegistry; import nl.hauntedmc.featureframework.loader.FeatureStartupCoordinator; +import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition; import nl.hauntedmc.featureframework.operation.reload.FeatureReloadResponse; import nl.hauntedmc.featureframework.operation.reload.FeatureReloadResult; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetRequest; -import nl.hauntedmc.featureframework.config.FeatureStoragePaths; -import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMap; -import nl.hauntedmc.featureframework.toolkit.io.localization.MessageMap; import nl.hauntedmc.featureframework.runtime.FeatureRuntime; import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry; +import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMap; +import nl.hauntedmc.featureframework.toolkit.io.localization.MessageMap; import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; import java.util.LinkedHashMap; @@ -47,19 +50,22 @@ final class FeatureInstanceController, C extends F private final Set preparationFailures = new LinkedHashSet<>(); private final Map defaults = new LinkedHashMap<>(); private final FeatureDependencyManager dependencyManager; + private final FeatureFrameworkObservations observations; FeatureInstanceController( FeatureInventory inventory, FeatureRuntime runtime, FeatureConfigurationRoot configuration, Function, C> contextFactory, - FrameworkLogger logger + FrameworkLogger logger, + FeatureFrameworkObservations observations ) { this.inventory = inventory; this.runtime = runtime; this.configuration = configuration; this.contextFactory = contextFactory; this.logger = logger; + this.observations = observations; registry = inventory.registry(); dependencyManager = new FeatureDependencyManager( inventory::resolveFeatureKey, @@ -185,41 +191,71 @@ FeatureReloadResponse reloadFeature(String featureName) { private boolean loadFeature(String featureName, SnapshotState reloadState) { String key = inventory.resolveFeatureKey(featureName); - if (key == null || registry.isFeatureLoaded(key) || preparationFailures.contains(key) - || inventory.hasStorageFailure(key)) return false; - ResolvedFeatureDefinition descriptor = registry.getAvailableFeature(key); - if (descriptor == null) return false; + if (key == null) return false; - boolean enabled = configuration.isFeatureEnabled(key); - runtime.mutableFeatureCatalog().setConfiguredEnabled(FeatureId.of(key), enabled); - if (!enabled || !inventory.missingPluginDependencies(key).isEmpty() - || !dependencyManager.areDependenciesMet(key)) return false; - - return FeatureStartupCoordinator.start( - reloadState, - () -> contextFactory.apply(descriptor), - descriptor::create, - feature -> { - captureDefaults(key, feature); - feature.context().prepare(feature); - }, - LifecycleFeature::initialize, - feature -> feature.context().activateServices(), - feature -> registry.registerLoadedFeature(key, feature), - () -> runtime.mutableFeatureCatalog().transition(FeatureId.of(key), FeatureState.STARTING), - () -> { - runtime.mutableFeatureCatalog().setUnavailableDependencies(FeatureId.of(key), Set.of()); - runtime.mutableFeatureCatalog().transition(FeatureId.of(key), FeatureState.ACTIVE); - logger.info("Feature loaded: " + key); - }, - failure -> { - runtime.mutableFeatureCatalog().fail(FeatureId.of(key), "startup", failure); - logger.error("Feature '" + key + "' failed to start.", failure); - }, - LifecycleFeature::cleanup, - FeatureHostContext::cleanup, - () -> registry.deregisterLoadedFeature(key) + FeatureId featureId = FeatureId.of(key); + FeatureFrameworkObservations.Operation observation = observations.start( + FeatureFrameworkOperationKind.FEATURE_LOAD, + featureId ); + FeatureFrameworkObservationScope scope = observation.openScope(); + try { + if (registry.isFeatureLoaded(key) || preparationFailures.contains(key) || inventory.hasStorageFailure(key)) { + observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null); + return false; + } + ResolvedFeatureDefinition descriptor = registry.getAvailableFeature(key); + if (descriptor == null) { + observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null); + return false; + } + + boolean enabled = configuration.isFeatureEnabled(key); + runtime.mutableFeatureCatalog().setConfiguredEnabled(featureId, enabled); + if (!enabled || !inventory.missingPluginDependencies(key).isEmpty() + || !dependencyManager.areDependenciesMet(key)) { + observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null); + return false; + } + + Throwable[] startupFailure = observation.isNoop() ? null : new Throwable[1]; + boolean loaded = FeatureStartupCoordinator.start( + reloadState, + () -> contextFactory.apply(descriptor), + descriptor::create, + feature -> { + captureDefaults(key, feature); + feature.context().prepare(feature); + }, + LifecycleFeature::initialize, + feature -> feature.context().activateServices(), + feature -> registry.registerLoadedFeature(key, feature), + () -> runtime.mutableFeatureCatalog().transition(featureId, FeatureState.STARTING), + () -> { + runtime.mutableFeatureCatalog().setUnavailableDependencies(featureId, Set.of()); + runtime.mutableFeatureCatalog().transition(featureId, FeatureState.ACTIVE); + logger.info("Feature loaded: " + key); + }, + failure -> { + if (startupFailure != null) startupFailure[0] = failure; + runtime.mutableFeatureCatalog().fail(featureId, "startup", failure); + logger.error("Feature '" + key + "' failed to start.", failure); + }, + LifecycleFeature::cleanup, + FeatureHostContext::cleanup, + () -> registry.deregisterLoadedFeature(key) + ); + observation.complete( + loaded ? FeatureFrameworkOperationOutcome.SUCCESS : FeatureFrameworkOperationOutcome.FAILURE, + startupFailure == null ? null : startupFailure[0] + ); + return loaded; + } catch (Throwable failure) { + observation.complete(FeatureFrameworkOperationOutcome.FAILURE, failure); + return throwUnchecked(failure); + } finally { + scope.close(); + } } List buildReloadOrder(String root) { @@ -293,5 +329,10 @@ private static Object copyValue(Object value) { return value; } + @SuppressWarnings("unchecked") + private static T throwUnchecked(Throwable failure) throws E { + throw (E) failure; + } + private record FeatureDefaults(ConfigMap config, MessageMap messages) { } } diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/architecture/SharedBoundaryTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/architecture/SharedBoundaryTest.java index 3820245..550c541 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/architecture/SharedBoundaryTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/architecture/SharedBoundaryTest.java @@ -21,6 +21,15 @@ void sharedSourcesHaveNoPlatformKnowledge() throws IOException { ); } + @Test + void sharedSourcesHaveNoTelemetryImplementationKnowledge() throws IOException { + assertSourcesExclude( + "io.opentelemetry", + "nl.hauntedmc.observability", + "hauntedobservability" + ); + } + @Test void hostFacadeDoesNotReabsorbLowLevelGraphMechanics() throws IOException { String source = Files.readString(Path.of( diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java new file mode 100644 index 0000000..bc6c6a0 --- /dev/null +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java @@ -0,0 +1,231 @@ +package nl.hauntedmc.featureframework.host; + +import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservation; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationContext; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FeatureFrameworkObservationsTest { + + @Test + void contextContainsOnlyBoundedOperationAndOptionalFeatureId() { + List components = Arrays.stream(FeatureFrameworkOperationContext.class.getRecordComponents()) + .map(RecordComponent::getName) + .toList(); + assertEquals(List.of("operation", "featureId"), components); + + var host = FeatureFrameworkOperationContext.host(FeatureFrameworkOperationKind.HOST_START); + assertTrue(host.featureId().isEmpty()); + + FeatureId featureId = FeatureId.of("lottery"); + var feature = FeatureFrameworkOperationContext.feature( + FeatureFrameworkOperationKind.FEATURE_LOAD, + featureId + ); + assertEquals(featureId, feature.featureId().orElseThrow()); + + assertThrows(IllegalArgumentException.class, () -> new FeatureFrameworkOperationContext( + FeatureFrameworkOperationKind.FEATURE_LOAD, + java.util.Optional.empty() + )); + assertThrows(IllegalArgumentException.class, () -> new FeatureFrameworkOperationContext( + FeatureFrameworkOperationKind.HOST_STOP, + java.util.Optional.of(featureId) + )); + } + + @Test + void observerFailuresAndScopeFailuresCannotChangeFrameworkWork() { + AtomicInteger starts = new AtomicInteger(); + AtomicInteger completions = new AtomicInteger(); + AtomicBoolean scopeClosed = new AtomicBoolean(); + FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context -> { + starts.incrementAndGet(); + return new FeatureFrameworkObservation() { + @Override + public FeatureFrameworkObservationScope openScope() { + return () -> { + scopeClosed.set(true); + throw new IllegalStateException("scope close failure"); + }; + } + + @Override + public void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure) { + completions.incrementAndGet(); + throw new IllegalStateException("completion failure"); + } + }; + }); + + assertEquals("ok", observations.observe( + FeatureFrameworkOperationKind.FEATURE_ENABLE, + FeatureId.of("lottery"), + () -> "ok", + ignored -> FeatureFrameworkOperationOutcome.SUCCESS, + ignored -> null + )); + assertEquals(1, starts.get()); + assertEquals(1, completions.get()); + assertTrue(scopeClosed.get()); + } + + @Test + void observationCompletesExactlyOnce() { + AtomicInteger completions = new AtomicInteger(); + FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context -> + recordingObservation(new AtomicReference<>(), completions)); + + FeatureFrameworkObservations.Operation operation = observations.start( + FeatureFrameworkOperationKind.FEATURE_LOAD, + FeatureId.of("lottery") + ); + operation.complete(FeatureFrameworkOperationOutcome.SUCCESS, null); + operation.complete(FeatureFrameworkOperationOutcome.FAILURE, new IllegalStateException()); + + assertEquals(1, completions.get()); + } + + @Test + void observerStartFailureFallsBackToNoop() { + FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context -> { + throw new IllegalStateException("adapter failure"); + }); + + assertEquals(42, observations.observe( + FeatureFrameworkOperationKind.GRAPH_RELOAD, + () -> 42, + ignored -> FeatureFrameworkOperationOutcome.SUCCESS, + ignored -> null + )); + } + + @Test + void disabledAndFilteredObserversSkipTerminalClassification() { + FeatureFrameworkObservations disabled = new FeatureFrameworkObservations( + nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver.noop()); + FeatureFrameworkObservations filtered = new FeatureFrameworkObservations( + context -> FeatureFrameworkObservation.noop()); + + assertEquals("disabled", disabled.observe( + FeatureFrameworkOperationKind.GRAPH_RELOAD, + () -> "disabled", + ignored -> { throw new AssertionError("disabled operation was classified"); }, + ignored -> { throw new AssertionError("disabled failure was inspected"); } + )); + assertEquals("filtered", filtered.observe( + FeatureFrameworkOperationKind.GRAPH_RELOAD, + () -> "filtered", + ignored -> { throw new AssertionError("filtered operation was classified"); }, + ignored -> { throw new AssertionError("filtered failure was inspected"); } + )); + } + + @Test + void classificationFailureCannotChangeFrameworkWork() { + AtomicReference outcome = new AtomicReference<>(); + AtomicReference observedFailure = new AtomicReference<>(); + FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context -> + new FeatureFrameworkObservation() { + @Override + public void completed(FeatureFrameworkOperationOutcome value, Throwable failure) { + outcome.set(value); + observedFailure.set(failure); + } + }); + + assertEquals("ok", observations.observe( + FeatureFrameworkOperationKind.GRAPH_RELOAD, + () -> "ok", + ignored -> { throw new IllegalStateException("classification failure"); }, + ignored -> null + )); + assertEquals(FeatureFrameworkOperationOutcome.FAILURE, outcome.get()); + assertEquals("classification failure", observedFailure.get().getMessage()); + } + + @Test + void scopeIsActiveAroundActualWork() { + AtomicBoolean active = new AtomicBoolean(); + AtomicBoolean closed = new AtomicBoolean(); + FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context -> + new FeatureFrameworkObservation() { + @Override + public FeatureFrameworkObservationScope openScope() { + active.set(true); + return () -> { + active.set(false); + closed.set(true); + }; + } + + @Override + public void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure) { + } + }); + + assertTrue(observations.observe( + FeatureFrameworkOperationKind.HOST_START, + active::get, + ignored -> FeatureFrameworkOperationOutcome.SUCCESS, + ignored -> null + )); + assertFalse(active.get()); + assertTrue(closed.get()); + } + + @Test + void workFailureIsReportedAndRethrownUnchanged() { + AtomicReference outcome = new AtomicReference<>(); + AtomicReference observedFailure = new AtomicReference<>(); + FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context -> + new FeatureFrameworkObservation() { + @Override + public void completed(FeatureFrameworkOperationOutcome value, Throwable failure) { + outcome.set(value); + observedFailure.set(failure); + } + }); + IllegalStateException failure = new IllegalStateException("boom"); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> observations.observe( + FeatureFrameworkOperationKind.FEATURE_RECREATE, + FeatureId.of("lottery"), + () -> { throw failure; }, + ignored -> FeatureFrameworkOperationOutcome.SUCCESS, + ignored -> null + )); + + assertTrue(thrown == failure); + assertEquals(FeatureFrameworkOperationOutcome.FAILURE, outcome.get()); + assertTrue(observedFailure.get() == failure); + } + + private static FeatureFrameworkObservation recordingObservation( + AtomicReference outcome, + AtomicInteger completions + ) { + return new FeatureFrameworkObservation() { + @Override + public void completed(FeatureFrameworkOperationOutcome value, Throwable failure) { + outcome.set(value); + completions.incrementAndGet(); + } + }; + } +} diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureHostTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureHostTest.java index 7e2e107..2e98063 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureHostTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureHostTest.java @@ -4,6 +4,10 @@ import nl.hauntedmc.featureframework.api.feature.FeatureId; import nl.hauntedmc.featureframework.api.feature.FeatureRole; import nl.hauntedmc.featureframework.api.feature.FeatureState; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservation; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationContext; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome; import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration; import nl.hauntedmc.featureframework.config.FeatureConfigHandler; import nl.hauntedmc.featureframework.lifecycle.FeatureLifecycleResources; @@ -20,8 +24,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import java.nio.file.Path; import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -41,6 +45,8 @@ void hostsCollectionAndReloadsProviderWithDependent() { ConsumerFeature.starts.set(0); List startupSequence = new ArrayList<>(); List transitions = new ArrayList<>(); + List observedContexts = new ArrayList<>(); + List observedOutcomes = new ArrayList<>(); FrameworkLogger logger = FrameworkLogger.noop(); ConfigService configService = new ConfigService( temporaryDirectory, logger, getClass().getClassLoader()); @@ -70,6 +76,15 @@ void hostsCollectionAndReloadsProviderWithDependent() { "test-host", "1.0.0", "test", runtime, configuration, features) .contextFactory(descriptor -> context( descriptor, configuration, localization, runtime, logger)) + .observer(context -> { + observedContexts.add(context); + return new FeatureFrameworkObservation() { + @Override + public void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure) { + observedOutcomes.add(outcome); + } + }; + }) .logger(logger) .build(); host.features().subscribe(snapshot -> transitions.add(snapshot.state())); @@ -85,14 +100,25 @@ void hostsCollectionAndReloadsProviderWithDependent() { host.features().find(FeatureId.of("Consumer")).orElseThrow().metadata().roles()); assertTrue(host.features().snapshot().stream() .allMatch(snapshot -> snapshot.state() == FeatureState.ACTIVE)); + assertEquals(FeatureFrameworkOperationKind.HOST_START, observedContexts.getFirst().operation()); + assertEquals( + List.of("provider", "consumer"), + observedContexts.stream() + .filter(context -> context.operation() == FeatureFrameworkOperationKind.FEATURE_LOAD) + .map(context -> context.featureId().orElseThrow().value()) + .toList() + ); var missingPreview = host.previewFileReset(FeatureId.of("Missing"), FeatureFileResetRequest.config()); assertFalse(missingPreview.valid()); assertTrue(missingPreview.feature().isBlank()); assertEquals("hello-1", host.capabilities().reference(GreetingApi.class).require().greeting()); long firstGeneration = host.capabilities().reference(GreetingApi.class).generation().orElseThrow(); + int beforeRecreate = observedContexts.size(); assertTrue(host.recreate(FeatureId.of("Provider")).success()); + assertEquals(FeatureFrameworkOperationKind.FEATURE_RECREATE, observedContexts.get(beforeRecreate).operation()); + assertEquals(FeatureFrameworkOperationKind.FEATURE_LOAD, observedContexts.get(beforeRecreate + 1).operation()); assertEquals(2, ProviderFeature.starts.get()); assertEquals(2, ConsumerFeature.starts.get()); assertEquals("hello-2", host.capabilities().reference(GreetingApi.class).require().greeting()); @@ -129,11 +155,26 @@ void hostsCollectionAndReloadsProviderWithDependent() { assertTrue(Files.notExists(providerDirectory.resolve("messages_EN.yml"))); assertTrue(Files.notExists(providerDirectory.resolve("messages_old-LANG.yml"))); + assertTrue(host.disable(FeatureId.of("Consumer")).success()); + assertTrue(host.enable(FeatureId.of("Consumer")).success()); + assertTrue(host.softReload(FeatureId.of("Consumer")).success()); host.stop(); assertEquals(RuntimeState.STOPPED, host.state()); assertTrue(host.features().snapshot().stream() .allMatch(snapshot -> snapshot.state() == FeatureState.DISABLED)); + assertTrue(observedContexts.stream().anyMatch( + context -> context.operation() == FeatureFrameworkOperationKind.GRAPH_RELOAD)); + assertTrue(observedContexts.stream().anyMatch( + context -> context.operation() == FeatureFrameworkOperationKind.FILE_RESET)); + assertTrue(observedContexts.stream().anyMatch( + context -> context.operation() == FeatureFrameworkOperationKind.FEATURE_DISABLE)); + assertTrue(observedContexts.stream().anyMatch( + context -> context.operation() == FeatureFrameworkOperationKind.FEATURE_ENABLE)); + assertTrue(observedContexts.stream().anyMatch( + context -> context.operation() == FeatureFrameworkOperationKind.FEATURE_SOFT_RELOAD)); + assertEquals(FeatureFrameworkOperationKind.HOST_STOP, observedContexts.getLast().operation()); + assertTrue(observedOutcomes.stream().noneMatch(FeatureFrameworkOperationOutcome::isFailure)); } private static TestContext context( diff --git a/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java b/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java index a46dad6..c658393 100644 --- a/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java +++ b/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java @@ -4,6 +4,7 @@ import nl.hauntedmc.featureframework.api.RuntimeState; import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver; import nl.hauntedmc.featureframework.api.service.CapabilityRegistry; import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration; import nl.hauntedmc.featureframework.config.FeatureConfigHandler; @@ -101,7 +102,8 @@ private PaperFeatureHost(Builder builder) { () -> CommandSync.apply(builder.plugin), localization::reloadLocalization, builder.afterHostResourcesReload, - frameworkLogger + frameworkLogger, + builder.observer ); } @@ -200,6 +202,7 @@ public static final class Builder

{ private PaperMessageDecorator messageDecorator = PaperMessageDecorator.identity(); private String overwriteCommandConflictsKey = "commands.overwrite-conflicts"; private Runnable afterHostResourcesReload = () -> { }; + private FeatureFrameworkObserver observer = FeatureFrameworkObserver.noop(); private final List> contributors = new ArrayList<>(); private final List> bootstrapCapabilities = new ArrayList<>(); private final List themes = new ArrayList<>(); @@ -236,6 +239,9 @@ public Builder overwriteCommandConflictsKey(String value) { public Builder afterHostResourcesReload(Runnable value) { afterHostResourcesReload = Objects.requireNonNull(value, "afterHostResourcesReload"); return this; } + public Builder observer(FeatureFrameworkObserver value) { + observer = Objects.requireNonNull(value, "observer"); return this; + } public Builder contribute(FeatureResourceContributor value) { contributors.add(Objects.requireNonNull(value, "contributor")); return this; } diff --git a/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java b/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java index 41b98ed..d814f58 100644 --- a/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java +++ b/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java @@ -7,6 +7,7 @@ import nl.hauntedmc.featureframework.api.RuntimeState; import nl.hauntedmc.featureframework.api.feature.FeatureCatalog; import nl.hauntedmc.featureframework.api.feature.FeatureId; +import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver; import nl.hauntedmc.featureframework.api.service.CapabilityRegistry; import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration; import nl.hauntedmc.featureframework.config.FeatureConfigHandler; @@ -91,7 +92,8 @@ private VelocityFeatureHost(Builder builder) { builder.afterGraphMutation, localization::reloadLocalization, builder.afterHostResourcesReload, - frameworkLogger + frameworkLogger, + builder.observer ); } @@ -200,6 +202,7 @@ public static final class Builder { private Function languageResolver = player -> Language.EN; private Runnable afterGraphMutation = () -> { }; private Runnable afterHostResourcesReload = () -> { }; + private FeatureFrameworkObserver observer = FeatureFrameworkObserver.noop(); private final List> contributors = new ArrayList<>(); private final List> bootstrapCapabilities = new ArrayList<>(); private final List themes = new ArrayList<>(); @@ -237,6 +240,9 @@ public Builder afterGraphMutation(Runnable value) { public Builder afterHostResourcesReload(Runnable value) { afterHostResourcesReload = Objects.requireNonNull(value, "afterHostResourcesReload"); return this; } + public Builder observer(FeatureFrameworkObserver value) { + observer = Objects.requireNonNull(value, "observer"); return this; + } public Builder contribute(FeatureResourceContributor value) { contributors.add(Objects.requireNonNull(value, "contributor")); return this; } diff --git a/pom.xml b/pom.xml index ca4ab5e..62453dd 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ nl.hauntedmc.platform haunted-library-parent - 1.2.0 + 1.3.0 @@ -102,8 +102,8 @@ 1.3.10 2.12.3 5.11.0 - 1.14.3 - 3.2.0 + 1.15.0 + 3.3.0 ${haunted.jpa.version} ${haunted.junit.version}