From d6dfaa49b4403db8d3da9956c27e0736fc2ab000 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:13:32 +0200 Subject: [PATCH 01/36] Add neutral FeatureFramework observation contract --- .../FeatureFrameworkOperationKind.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationKind.java 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; + } +} From 0cbf728c736021b72f41bd318069a3f89649f8b7 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:13:43 +0200 Subject: [PATCH 02/36] Add neutral FeatureFramework observation contract --- .../FeatureFrameworkOperationOutcome.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationOutcome.java 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; + } +} From ceb8e46caffee3710b2ca5554717429048e5cf1e Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:13:59 +0200 Subject: [PATCH 03/36] Add neutral FeatureFramework observation contract --- .../FeatureFrameworkOperationContext.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationContext.java 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"))); + } +} From 708bbd639bcf93cff90fb2044585ab211cc7b7d2 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:14:08 +0200 Subject: [PATCH 04/36] Add neutral FeatureFramework observation contract --- .../FeatureFrameworkObservationScope.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservationScope.java 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. + } +} From bd16a0fb968c1674df4a70a2946d40f4b10bc97f Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:14:17 +0200 Subject: [PATCH 05/36] Add neutral FeatureFramework observation contract --- .../FeatureFrameworkObservation.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservation.java 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. + } +} From 152ce16e06d06d19ebddbb1bffecf44734cbf013 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:14:28 +0200 Subject: [PATCH 06/36] Add neutral FeatureFramework observation contract --- .../observation/FeatureFrameworkObserver.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObserver.java 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..e9e1843 --- /dev/null +++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObserver.java @@ -0,0 +1,23 @@ +package nl.hauntedmc.featureframework.api.observation; + +/** Vendor-neutral observer for meaningful FeatureFramework lifecycle and runtime operations. */ +@FunctionalInterface +public interface FeatureFrameworkObserver { + + /** Starts one observation. Observer failures are isolated by FeatureFramework. */ + 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(); + } +} From fbca3800c56cd844307958bdd53235258b95ea8f Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:14:48 +0200 Subject: [PATCH 07/36] Add fail-open FeatureFramework observation dispatcher --- .../host/FeatureFrameworkObservations.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java 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..5c7fff4 --- /dev/null +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java @@ -0,0 +1,111 @@ +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; + +/** 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(); + } + + boolean isEnabled() { + return enabled; + } + + Operation start(FeatureFrameworkOperationKind kind) { + return start(FeatureFrameworkOperationContext.host(kind)); + } + + Operation start(FeatureFrameworkOperationKind kind, FeatureId featureId) { + return start(FeatureFrameworkOperationContext.feature(kind, featureId)); + } + + private Operation start(FeatureFrameworkOperationContext context) { + if (!enabled) { + return NOOP_OPERATION; + } + 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(); + } + + FeatureFrameworkObservationScope openScope() { + if (completed == null) { + 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 (completed == null || !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. + } + } + } +} From bd348e472adb3fad269eb78664dee48b2df25d9a Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:15:35 +0200 Subject: [PATCH 08/36] Align FeatureFramework with observability foundation releases --- pom.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index ca4ab5e..63c65a3 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} @@ -211,4 +211,4 @@ - + \ No newline at end of file From fea707c47f875da22cd9d7dfc932c9504fc8f5f3 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:15:50 +0200 Subject: [PATCH 09/36] Use HauntedPlatform 1.3.0 CI policy --- .github/workflows/ci-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 3d361852283612c5af09cf4874201c1eb063a4be Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:16:23 +0200 Subject: [PATCH 10/36] Inject optional observer through host composition --- .../host/FeatureHostComposition.java | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) 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(); } From 4c98fdd2cc064f36e191a9c794b2ce6c9cb497cf Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:17:01 +0200 Subject: [PATCH 11/36] Expose observer through Paper host builder --- .../featureframework/paper/host/PaperFeatureHost.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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; } From 2029b76a444248ebc24f40da959a14196104edd7 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:17:37 +0200 Subject: [PATCH 12/36] Expose observer through Velocity host builder --- .../velocity/host/VelocityFeatureHost.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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; } From 45355cec0475794bd373a95526806a1cf084307e Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:19:00 +0200 Subject: [PATCH 13/36] Centralize fail-open observation wrapping --- .../host/FeatureFrameworkObservations.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 index 5c7fff4..9a66b96 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java @@ -10,6 +10,8 @@ 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 { @@ -36,6 +38,49 @@ Operation start(FeatureFrameworkOperationKind kind, FeatureId featureId) { return start(FeatureFrameworkOperationContext.feature(kind, featureId)); } + T observe( + FeatureFrameworkOperationKind kind, + Supplier work, + Function outcome, + Function failure + ) { + return observe(start(kind), work, outcome, failure); + } + + T observe( + FeatureFrameworkOperationKind kind, + FeatureId featureId, + Supplier work, + Function outcome, + Function failure + ) { + return observe(start(kind, featureId), work, outcome, failure); + } + + private T observe( + Operation operation, + Supplier work, + Function outcome, + Function failure + ) { + Objects.requireNonNull(work, "work"); + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(failure, "failure"); + try (FeatureFrameworkObservationScope ignored = operation.openScope()) { + try { + T result = work.get(); + operation.complete( + Objects.requireNonNull(outcome.apply(result), "observation outcome"), + failure.apply(result) + ); + return result; + } catch (RuntimeException | Error throwable) { + operation.complete(FeatureFrameworkOperationOutcome.FAILURE, throwable); + throw throwable; + } + } + } + private Operation start(FeatureFrameworkOperationContext context) { if (!enabled) { return NOOP_OPERATION; From 8e1740d3d32eea2d6502319b1b3bba9dfb26d37b Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:19:30 +0200 Subject: [PATCH 14/36] Preserve checked failures in observed work --- .../host/FeatureFrameworkObservations.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 index 9a66b96..fba279c 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java @@ -74,9 +74,9 @@ private T observe( failure.apply(result) ); return result; - } catch (RuntimeException | Error throwable) { + } catch (Throwable throwable) { operation.complete(FeatureFrameworkOperationOutcome.FAILURE, throwable); - throw throwable; + return throwUnchecked(throwable); } } } @@ -153,4 +153,9 @@ public void close() { } } } + + @SuppressWarnings("unchecked") + private static T throwUnchecked(Throwable failure) throws E { + throw (E) failure; + } } From 36a35a7b89977bd3b306d197370f89913e620f87 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:20:49 +0200 Subject: [PATCH 15/36] Observe actual feature startup lifecycle --- .../host/FeatureInstanceController.java | 114 ++++++++++++------ 1 file changed, 76 insertions(+), 38 deletions(-) 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..266b505 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,68 @@ 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; - - boolean enabled = configuration.isFeatureEnabled(key); - runtime.mutableFeatureCatalog().setConfiguredEnabled(FeatureId.of(key), enabled); - if (!enabled || !inventory.missingPluginDependencies(key).isEmpty() - || !dependencyManager.areDependenciesMet(key)) return false; + if (key == null) 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 ); + try (FeatureFrameworkObservationScope ignored = observation.openScope()) { + 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 = observations.isEnabled() ? new Throwable[1] : null; + 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); + } } List buildReloadOrder(String root) { @@ -293,5 +326,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) { } } From 47e61f0c732a7d6c3e3e86fcaad59d3e6534f554 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:22:20 +0200 Subject: [PATCH 16/36] Observe FeatureFramework host lifecycle operations --- .../featureframework/host/FeatureHost.java | 184 +++++++++++++++--- 1 file changed, 154 insertions(+), 30 deletions(-) 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..7c85856 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,48 @@ 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.disable.FeatureDisableResult; import nl.hauntedmc.featureframework.operation.enable.FeatureEnableResponse; +import nl.hauntedmc.featureframework.operation.enable.FeatureEnableResult; 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.reload.FeatureReloadResult; 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.operation.softreload.FeatureSoftReloadResult; 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 +70,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 +89,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 +115,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 +152,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 +173,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 +202,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 +274,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 +509,24 @@ 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); + try (FeatureFrameworkObservationScope ignored = observation.openScope()) { + 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); + } } private Throwable unloadAll() { @@ -516,6 +592,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 +640,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"); From ed4c9e13c2a8468a6f520c44c1fa79a0658e8bb8 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:23:53 +0200 Subject: [PATCH 17/36] Document FeatureFramework observation contract --- docs/OBSERVATION.md | 101 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/OBSERVATION.md diff --git a/docs/OBSERVATION.md b/docs/OBSERVATION.md new file mode 100644 index 0000000..3c692a9 --- /dev/null +++ b/docs/OBSERVATION.md @@ -0,0 +1,101 @@ +# 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. + +Observer start, scope activation, completion, and scope cleanup are isolated from FeatureFramework behavior. An observability adapter must be non-blocking; FeatureFramework never requires one to be present. + +## 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. From 46e5ffb98b6076d623c99c841be342b556ae1918 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:24:17 +0200 Subject: [PATCH 18/36] Test neutral observation fail-open contract --- .../FeatureFrameworkObservationsTest.java | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java 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..76a1e40 --- /dev/null +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java @@ -0,0 +1,187 @@ +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 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(); + } + }; + } +} From e791eeea205c9a140038d975540912afa4ab6add Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:27:00 +0200 Subject: [PATCH 19/36] Avoid unused observation scope warnings --- .../host/FeatureFrameworkObservations.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 index fba279c..feb15ef 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java @@ -31,10 +31,16 @@ boolean isEnabled() { } 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)); } @@ -66,7 +72,8 @@ private T observe( Objects.requireNonNull(work, "work"); Objects.requireNonNull(outcome, "outcome"); Objects.requireNonNull(failure, "failure"); - try (FeatureFrameworkObservationScope ignored = operation.openScope()) { + FeatureFrameworkObservationScope scope = operation.openScope(); + try { try { T result = work.get(); operation.complete( @@ -78,13 +85,12 @@ private T observe( operation.complete(FeatureFrameworkOperationOutcome.FAILURE, throwable); return throwUnchecked(throwable); } + } finally { + scope.close(); } } private Operation start(FeatureFrameworkOperationContext context) { - if (!enabled) { - return NOOP_OPERATION; - } try { FeatureFrameworkObservation observation = observer.start(context); if (observation == null || observation == FeatureFrameworkObservation.noop()) { From f01bb9659295d68b84d7e4263df22a143b68f639 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:28:09 +0200 Subject: [PATCH 20/36] Close feature observation scope explicitly --- .../featureframework/host/FeatureInstanceController.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 266b505..ed5a459 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 @@ -198,7 +198,8 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { FeatureFrameworkOperationKind.FEATURE_LOAD, featureId ); - try (FeatureFrameworkObservationScope ignored = observation.openScope()) { + FeatureFrameworkObservationScope scope = observation.openScope(); + try { if (registry.isFeatureLoaded(key) || preparationFailures.contains(key) || inventory.hasStorageFailure(key)) { observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null); return false; @@ -252,6 +253,8 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { } catch (Throwable failure) { observation.complete(FeatureFrameworkOperationOutcome.FAILURE, failure); return throwUnchecked(failure); + } finally { + scope.close(); } } From 096aeaff4227492b57fd5baa33c33e969e8389d3 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:30:17 +0200 Subject: [PATCH 21/36] Guard neutral observation API boundary --- .../api/ObservationBoundaryTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java 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..8560c78 --- /dev/null +++ b/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java @@ -0,0 +1,27 @@ +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 publicApiHasNoTelemetryVendorOrHauntedObservabilityDependency() 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("hauntedobservability"), + () -> file + " contains HauntedObservability coupling" + ); + } + } + } +} From e3b53b67c7b3a51bf181d574955279a19e39affe Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:32:32 +0200 Subject: [PATCH 22/36] Close host observation scope explicitly --- .../nl/hauntedmc/featureframework/host/FeatureHost.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 7c85856..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 @@ -16,13 +16,10 @@ import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition; import nl.hauntedmc.featureframework.operation.FeatureOperationCoordinator; import nl.hauntedmc.featureframework.operation.disable.FeatureDisableResponse; -import nl.hauntedmc.featureframework.operation.disable.FeatureDisableResult; import nl.hauntedmc.featureframework.operation.enable.FeatureEnableResponse; -import nl.hauntedmc.featureframework.operation.enable.FeatureEnableResult; 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.reload.FeatureReloadResult; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetPreview; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetRequest; import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetResponse; @@ -30,7 +27,6 @@ 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.operation.softreload.FeatureSoftReloadResult; import nl.hauntedmc.featureframework.runtime.FeatureRuntime; import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry; import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger; @@ -510,7 +506,8 @@ public void stop() { private synchronized void stopLocked() { FeatureFrameworkObservations.Operation observation = observations.start(FeatureFrameworkOperationKind.HOST_STOP); - try (FeatureFrameworkObservationScope ignored = observation.openScope()) { + FeatureFrameworkObservationScope scope = observation.openScope(); + try { if (runtime.state() == RuntimeState.STOPPED) { observation.complete(FeatureFrameworkOperationOutcome.NO_CHANGE, null); return; @@ -526,6 +523,8 @@ private synchronized void stopLocked() { } catch (Throwable failure) { observation.complete(FeatureFrameworkOperationOutcome.FAILURE, failure); throwUnchecked(failure); + } finally { + scope.close(); } } From 005bbb172aa965a9b225edd444f8007a69231176 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 13:33:47 +0200 Subject: [PATCH 23/36] Verify observed host lifecycle semantics --- .../host/FeatureHostTest.java | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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( From 43407027116a2a310b857de438365d74bc7a385c Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:26:33 +0200 Subject: [PATCH 24/36] Harden observation no-op fast path --- .../host/FeatureFrameworkObservations.java | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) 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 index feb15ef..276e183 100644 --- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java +++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java @@ -26,10 +26,6 @@ final class FeatureFrameworkObservations { this.enabled = observer != FeatureFrameworkObserver.noop(); } - boolean isEnabled() { - return enabled; - } - Operation start(FeatureFrameworkOperationKind kind) { if (!enabled) { return NOOP_OPERATION; @@ -50,6 +46,10 @@ T observe( Function outcome, Function failure ) { + Objects.requireNonNull(work, "work"); + if (!enabled) { + return work.get(); + } return observe(start(kind), work, outcome, failure); } @@ -60,6 +60,10 @@ T observe( Function outcome, Function failure ) { + Objects.requireNonNull(work, "work"); + if (!enabled) { + return work.get(); + } return observe(start(kind, featureId), work, outcome, failure); } @@ -69,17 +73,16 @@ private T observe( Function outcome, Function failure ) { - Objects.requireNonNull(work, "work"); + if (operation.isNoop()) { + return work.get(); + } Objects.requireNonNull(outcome, "outcome"); Objects.requireNonNull(failure, "failure"); FeatureFrameworkObservationScope scope = operation.openScope(); try { try { T result = work.get(); - operation.complete( - Objects.requireNonNull(outcome.apply(result), "observation outcome"), - failure.apply(result) - ); + completeFromResult(operation, result, outcome, failure); return result; } catch (Throwable throwable) { operation.complete(FeatureFrameworkOperationOutcome.FAILURE, throwable); @@ -90,6 +93,22 @@ private T observe( } } + 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); @@ -111,8 +130,12 @@ private Operation(FeatureFrameworkObservation observation) { this.completed = observation == FeatureFrameworkObservation.noop() ? null : new AtomicBoolean(); } + boolean isNoop() { + return completed == null; + } + FeatureFrameworkObservationScope openScope() { - if (completed == null) { + if (isNoop()) { return FeatureFrameworkObservationScope.noop(); } try { @@ -128,7 +151,7 @@ FeatureFrameworkObservationScope openScope() { void complete(FeatureFrameworkOperationOutcome outcome, Throwable failure) { Objects.requireNonNull(outcome, "outcome"); - if (completed == null || !completed.compareAndSet(false, true)) { + if (isNoop() || !completed.compareAndSet(false, true)) { return; } try { From 4a6da5c2af56cb3333ed475b22e0487567eac45a Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:28:09 +0200 Subject: [PATCH 25/36] Avoid filtered feature-load observation allocation --- .../featureframework/host/FeatureInstanceController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ed5a459..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 @@ -218,7 +218,7 @@ private boolean loadFeature(String featureName, SnapshotState reloadState) { return false; } - Throwable[] startupFailure = observations.isEnabled() ? new Throwable[1] : null; + Throwable[] startupFailure = observation.isNoop() ? null : new Throwable[1]; boolean loaded = FeatureStartupCoordinator.start( reloadState, () -> contextFactory.apply(descriptor), From b1f051e902bc70940d7b367ba3e235d83849904c Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:28:26 +0200 Subject: [PATCH 26/36] Strengthen telemetry architecture boundary --- .../featureframework/api/ObservationBoundaryTest.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 8560c78..e79474c 100644 --- a/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java +++ b/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java @@ -11,16 +11,20 @@ class ObservationBoundaryTest { @Test - void publicApiHasNoTelemetryVendorOrHauntedObservabilityDependency() throws IOException { + 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("hauntedobservability"), + source.contains("nl.hauntedmc.observability"), () -> file + " contains HauntedObservability coupling" ); + assertFalse( + source.contains("hauntedobservability"), + () -> file + " contains legacy HauntedObservability coupling" + ); } } } From 101139bbe2957f6e5df30239a1db1a48caa34f9a Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:28:49 +0200 Subject: [PATCH 27/36] Enforce neutral core observation boundary --- .../architecture/SharedBoundaryTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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( From 6d9460a7834be4b6cfe460b83c1b5991b9462519 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:29:35 +0200 Subject: [PATCH 28/36] Cover observation fast-path isolation --- .../FeatureFrameworkObservationsTest.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 index 76a1e40..bc6c6a0 100644 --- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java +++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java @@ -115,6 +115,50 @@ void observerStartFailureFallsBackToNoop() { )); } + @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(); From 2a5f7ff3fcc64c519526c340aea7bc4bebe08f8d Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:29:46 +0200 Subject: [PATCH 29/36] Clarify observer failure isolation contract --- .../api/observation/FeatureFrameworkObserver.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index e9e1843..d940778 100644 --- 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 @@ -4,7 +4,10 @@ @FunctionalInterface public interface FeatureFrameworkObserver { - /** Starts one observation. Observer failures are isolated by FeatureFramework. */ + /** + * Starts one observation. Runtime exceptions from observation callbacks are isolated by + * FeatureFramework; fatal JVM errors are not swallowed. + */ FeatureFrameworkObservation start(FeatureFrameworkOperationContext context); /** Returns the reusable no-op observer. */ From dac8388804f0358e966c740289691ceedb5e6a44 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:31:15 +0200 Subject: [PATCH 30/36] Clarify observation failure and fast-path semantics --- docs/OBSERVATION.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/OBSERVATION.md b/docs/OBSERVATION.md index 3c692a9..6129c47 100644 --- a/docs/OBSERVATION.md +++ b/docs/OBSERVATION.md @@ -31,7 +31,9 @@ The dependency-free API consists of: - `FeatureFrameworkOperationKind`, the stable operation vocabulary; - `FeatureFrameworkOperationOutcome`, a bounded terminal classification. -Observer start, scope activation, completion, and scope cleanup are isolated from FeatureFramework behavior. An observability adapter must be non-blocking; FeatureFramework never requires one to be present. +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. + +When the default observer is used, or when a custom observer filters an operation by returning `FeatureFrameworkObservation.noop()`, FeatureFramework executes the original lifecycle work directly without constructing observation context or running terminal observation classification. ## Operation vocabulary From 528d10390665e8a4c90f61998b12eac93c815dbc Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:31:26 +0200 Subject: [PATCH 31/36] Align observer Javadoc with callback policy --- .../api/observation/FeatureFrameworkObserver.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d940778..3a5e715 100644 --- 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 @@ -6,7 +6,7 @@ public interface FeatureFrameworkObserver { /** * Starts one observation. Runtime exceptions from observation callbacks are isolated by - * FeatureFramework; fatal JVM errors are not swallowed. + * FeatureFramework; Java {@link Error}s are not swallowed. */ FeatureFrameworkObservation start(FeatureFrameworkOperationContext context); From b5152063d987b4332863408a7858395d10650ea8 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:32:07 +0200 Subject: [PATCH 32/36] Normalize root POM formatting --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 63c65a3..08d2273 100644 --- a/pom.xml +++ b/pom.xml @@ -205,10 +205,10 @@ maven-javadoc-plugin - javadocjar + javadocjar - \ No newline at end of file + From 8e3a6a65503be486a7430bf02c61d1849bc112ec Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:33:01 +0200 Subject: [PATCH 33/36] Fix release profile XML --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 08d2273..62453dd 100644 --- a/pom.xml +++ b/pom.xml @@ -205,7 +205,7 @@ maven-javadoc-plugin - javadocjar + javadocjar From 66a11f9c308bb7610dfb92fc01adbd31a03e50cc Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:33:53 +0200 Subject: [PATCH 34/36] Link lifecycle observation documentation --- docs/README.md | 1 + 1 file changed, 1 insertion(+) 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) From e7f783bf5aa84e8181632e3ea9eed0138a813b02 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:35:50 +0200 Subject: [PATCH 35/36] Document FeatureFramework 1.7 release order --- docs/RELEASE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 From df4ed64b8b89d427f2b24a38643490fbcd017f20 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Tue, 25 Aug 2026 14:38:08 +0200 Subject: [PATCH 36/36] Clarify observation filtering fast path --- docs/OBSERVATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/OBSERVATION.md b/docs/OBSERVATION.md index 6129c47..ee1f8c1 100644 --- a/docs/OBSERVATION.md +++ b/docs/OBSERVATION.md @@ -33,7 +33,7 @@ The dependency-free API consists of: 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. -When the default observer is used, or when a custom observer filters an operation by returning `FeatureFrameworkObservation.noop()`, FeatureFramework executes the original lifecycle work directly without constructing observation context or running terminal observation classification. +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