diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml
index dd788e9..d966685 100644
--- a/.github/workflows/ci-lint.yml
+++ b/.github/workflows/ci-lint.yml
@@ -13,7 +13,7 @@ permissions:
jobs:
maven-policy:
name: Shared Maven policy
- uses: HauntedMC/HauntedPlatform/.github/workflows/maven-ci.yml@v1.2.0
+ uses: HauntedMC/HauntedPlatform/.github/workflows/maven-ci.yml@v1.3.0
with:
maven-command: ./mvnw -U -B -ntp -DskipTests verify
secrets:
diff --git a/docs/OBSERVATION.md b/docs/OBSERVATION.md
new file mode 100644
index 0000000..ee1f8c1
--- /dev/null
+++ b/docs/OBSERVATION.md
@@ -0,0 +1,103 @@
+# Lifecycle operation observation
+
+FeatureFramework exposes an optional, vendor-neutral observation SPI for measuring or tracing meaningful host and feature lifecycle operations without depending on OpenTelemetry, HauntedObservability, or another telemetry implementation.
+
+## Attach an observer
+
+Paper and Velocity hosts accept an observer during construction:
+
+```java
+PaperFeatureHost.builder(plugin, ApiRoot.class, features)
+ .observer(observer)
+ .build();
+```
+
+```java
+VelocityFeatureHost.builder(plugin, proxy, logger, dataDirectory, ApiRoot.class, features)
+ .observer(observer)
+ .build();
+```
+
+The observer belongs to that host instance. There is no static observer registry, service locator, or global registration. Existing builders remain source-compatible because the default is `FeatureFrameworkObserver.noop()`.
+
+## Public contract
+
+The dependency-free API consists of:
+
+- `FeatureFrameworkObserver`, which starts one observation;
+- `FeatureFrameworkObservation`, which optionally activates adapter-specific context and receives terminal completion;
+- `FeatureFrameworkObservationScope`, which propagates adapter context while FeatureFramework executes the operation;
+- `FeatureFrameworkOperationContext`, which contains only the bounded operation kind and optional framework-owned `FeatureId`;
+- `FeatureFrameworkOperationKind`, the stable operation vocabulary;
+- `FeatureFrameworkOperationOutcome`, a bounded terminal classification.
+
+Runtime exceptions from observer start, scope activation, completion, and scope cleanup are isolated from FeatureFramework behavior. Java `Error`s are not swallowed. An observability adapter must be non-blocking; FeatureFramework never requires one to be present.
+
+With the default no-op observer, FeatureFramework executes lifecycle work without constructing an observation context or running terminal observation classification. A custom observer necessarily receives the bounded context so it can decide whether to observe an operation; if it filters that operation by returning `FeatureFrameworkObservation.noop()`, FeatureFramework then skips scope activation, terminal classification, and completion for that operation.
+
+## Operation vocabulary
+
+The initial contract observes:
+
+- `HOST_START`
+- `HOST_STOP`
+- `FEATURE_LOAD`
+- `FEATURE_ENABLE`
+- `FEATURE_DISABLE`
+- `FEATURE_RECREATE`
+- `FEATURE_SOFT_RELOAD`
+- `GRAPH_RELOAD`
+- `FILE_RESET`
+
+`FEATURE_LOAD` is emitted from the one actual startup path in `FeatureInstanceController`. It therefore covers initial startup, explicit enable, dependency-driven startup, recreation, graph reload, and reset-driven restart. Higher-level operations are emitted only at their public serialized host boundary, so internal reload recursion does not create duplicate `FEATURE_ENABLE`/`FEATURE_DISABLE` operations.
+
+A normal recreation can therefore look like:
+
+```text
+FEATURE_RECREATE lottery
+└── FEATURE_LOAD lottery
+```
+
+A graph reload can contain multiple nested `FEATURE_LOAD` operations without pretending that each internal reconciliation step was a separately requested enable/recreate command.
+
+## Outcomes
+
+The stable outcomes are:
+
+- `SUCCESS` — requested lifecycle work completed;
+- `NO_CHANGE` — the requested state already held, such as disabling an already-unloaded feature;
+- `SKIPPED` — a bounded precondition prevented work, such as a missing feature/dependency or unavailable reset target;
+- `FAILURE` — lifecycle work was attempted and failed.
+
+Where FeatureFramework owns a concrete `Throwable`, it is supplied separately as diagnostic context. Exception messages must not be turned into metric labels.
+
+## Metadata and cardinality boundary
+
+`FeatureFrameworkOperationContext` contains exactly:
+
+- a `FeatureFrameworkOperationKind`; and
+- a `FeatureId` only when the operation is feature-scoped.
+
+It deliberately does **not** expose configuration values, file paths, plugin objects, dependency lists, command arguments, player identifiers, server addresses, database information, SQL/query text, arbitrary caller strings, or arbitrary attribute maps.
+
+The operation kind and FeatureId are the intended bounded dimensions for metrics. Adapters may attach richer failure detail to traces/logs according to their own privacy policy, but not as metric labels.
+
+## Layering with DataRegistry and DataProvider
+
+The three neutral SPIs represent different ownership layers:
+
+```text
+FeatureFramework lifecycle operation
+├── DataRegistry semantic/domain operation
+└── DataProvider storage operation
+```
+
+FeatureFramework answers **which feature or host lifecycle operation is running**. DataRegistry answers **which registry/domain operation is running**. DataProvider answers **which backend/storage operation is running**. HauntedObservability should preserve this hierarchy rather than duplicating storage or registry instrumentation inside FeatureFramework.
+
+FeatureFramework does not register DataRegistry or DataProvider observers itself. The application composition root attaches all three observers to their respective runtimes.
+
+## Scope boundary
+
+This release intentionally does not observe every configuration read, dependency check, repository call, resource cleanup callback, event/listener invocation, or preview operation. `previewFileReset(...)` remains a read-only operation and is not observed.
+
+FeatureFramework itself has no OpenTelemetry or HauntedObservability dependency. The later HauntedObservability FeatureFramework integration will implement this neutral SPI.
diff --git a/docs/README.md b/docs/README.md
index 018a08a..a004d84 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -31,6 +31,7 @@ If FeatureFramework is new to you, read the [feature mental model](concepts/FEAT
- [Architecture](ARCHITECTURE.md)
- [Threading](THREADING.md)
+- [Lifecycle operation observation](OBSERVATION.md)
- [Version migration notes](MIGRATION.md)
- [Coordinated release process](RELEASE.md)
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index 3c5a3b2..1b228e3 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -6,9 +6,10 @@ versions separately.
The reactor publishes `featureframework-theme-api` with the other framework artifacts. Publish FeatureFramework before
any separately versioned theme adapter that targets the new API.
-For the 1.6.0 boundary release, publish FeatureFramework first, then ProxyFeatures 3.6.0, then
-ServerFeatures 3.6.0. The consumer releases carry the private contracts and platform implementations
-removed from the public framework.
+For the 1.7.0 observability boundary, HauntedPlatform 1.3.0, DataProvider 3.3.0, and DataRegistry 1.15.0 must already be
+published. Publish FeatureFramework 1.7.0 before HauntedObservability 1.0.0. After HauntedObservability is published,
+align the ecosystem through HauntedPlatform 1.4.0 before ServerFeatures and ProxyFeatures adopt the observability runtime.
+FeatureFramework remains vendor-neutral and does not depend on HauntedObservability.
## 1. Prepare
diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservation.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservation.java
new file mode 100644
index 0000000..48fbf4d
--- /dev/null
+++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservation.java
@@ -0,0 +1,30 @@
+package nl.hauntedmc.featureframework.api.observation;
+
+/** One in-flight FeatureFramework operation returned by a {@link FeatureFrameworkObserver}. */
+public interface FeatureFrameworkObservation {
+
+ /**
+ * Activates adapter-specific context while FeatureFramework executes work for this observation.
+ * Implementations should return a non-null scope that is closed on the same thread.
+ */
+ default FeatureFrameworkObservationScope openScope() {
+ return FeatureFrameworkObservationScope.noop();
+ }
+
+ /** Completes the observation with one stable outcome and optional diagnostic failure. */
+ void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure);
+
+ /** Returns the reusable no-op observation. */
+ static FeatureFrameworkObservation noop() {
+ return NoopFeatureFrameworkObservation.INSTANCE;
+ }
+}
+
+enum NoopFeatureFrameworkObservation implements FeatureFrameworkObservation {
+ INSTANCE;
+
+ @Override
+ public void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure) {
+ // Intentionally empty.
+ }
+}
diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservationScope.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservationScope.java
new file mode 100644
index 0000000..ed80f32
--- /dev/null
+++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObservationScope.java
@@ -0,0 +1,23 @@
+package nl.hauntedmc.featureframework.api.observation;
+
+/** Adapter-specific context scope activated while FeatureFramework executes observed work. */
+@FunctionalInterface
+public interface FeatureFrameworkObservationScope extends AutoCloseable {
+
+ @Override
+ void close();
+
+ /** Returns the reusable no-op scope. */
+ static FeatureFrameworkObservationScope noop() {
+ return NoopFeatureFrameworkObservationScope.INSTANCE;
+ }
+}
+
+enum NoopFeatureFrameworkObservationScope implements FeatureFrameworkObservationScope {
+ INSTANCE;
+
+ @Override
+ public void close() {
+ // Intentionally empty.
+ }
+}
diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObserver.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObserver.java
new file mode 100644
index 0000000..3a5e715
--- /dev/null
+++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkObserver.java
@@ -0,0 +1,26 @@
+package nl.hauntedmc.featureframework.api.observation;
+
+/** Vendor-neutral observer for meaningful FeatureFramework lifecycle and runtime operations. */
+@FunctionalInterface
+public interface FeatureFrameworkObserver {
+
+ /**
+ * Starts one observation. Runtime exceptions from observation callbacks are isolated by
+ * FeatureFramework; Java {@link Error}s are not swallowed.
+ */
+ FeatureFrameworkObservation start(FeatureFrameworkOperationContext context);
+
+ /** Returns the reusable no-op observer. */
+ static FeatureFrameworkObserver noop() {
+ return NoopFeatureFrameworkObserver.INSTANCE;
+ }
+}
+
+enum NoopFeatureFrameworkObserver implements FeatureFrameworkObserver {
+ INSTANCE;
+
+ @Override
+ public FeatureFrameworkObservation start(FeatureFrameworkOperationContext context) {
+ return FeatureFrameworkObservation.noop();
+ }
+}
diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationContext.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationContext.java
new file mode 100644
index 0000000..912f32e
--- /dev/null
+++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationContext.java
@@ -0,0 +1,44 @@
+package nl.hauntedmc.featureframework.api.observation;
+
+import nl.hauntedmc.featureframework.api.feature.FeatureId;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Stable, payload-free metadata for one FeatureFramework operation.
+ *
+ *
The context deliberately contains only a bounded operation kind and, for feature-scoped
+ * operations, the framework-owned {@link FeatureId}. Configuration values, file paths, plugin
+ * objects, dependency lists, command input, player data, and arbitrary caller strings do not belong here.
+ */
+public record FeatureFrameworkOperationContext(
+ FeatureFrameworkOperationKind operation,
+ Optional featureId
+) {
+
+ public FeatureFrameworkOperationContext {
+ Objects.requireNonNull(operation, "operation");
+ featureId = featureId == null ? Optional.empty() : featureId;
+ if (operation.featureScoped() != featureId.isPresent()) {
+ throw new IllegalArgumentException(
+ operation.featureScoped()
+ ? "Feature-scoped operations require a FeatureId."
+ : "Host-scoped operations must not include a FeatureId."
+ );
+ }
+ }
+
+ /** Creates a host-scoped context. */
+ public static FeatureFrameworkOperationContext host(FeatureFrameworkOperationKind operation) {
+ return new FeatureFrameworkOperationContext(operation, Optional.empty());
+ }
+
+ /** Creates a feature-scoped context. */
+ public static FeatureFrameworkOperationContext feature(
+ FeatureFrameworkOperationKind operation,
+ FeatureId featureId
+ ) {
+ return new FeatureFrameworkOperationContext(operation, Optional.of(Objects.requireNonNull(featureId, "featureId")));
+ }
+}
diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationKind.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationKind.java
new file mode 100644
index 0000000..7deb03f
--- /dev/null
+++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationKind.java
@@ -0,0 +1,25 @@
+package nl.hauntedmc.featureframework.api.observation;
+
+/** Stable, low-cardinality operation kinds exposed by FeatureFramework observation. */
+public enum FeatureFrameworkOperationKind {
+ HOST_START(false),
+ HOST_STOP(false),
+ FEATURE_LOAD(true),
+ FEATURE_ENABLE(true),
+ FEATURE_DISABLE(true),
+ FEATURE_RECREATE(true),
+ FEATURE_SOFT_RELOAD(true),
+ GRAPH_RELOAD(false),
+ FILE_RESET(true);
+
+ private final boolean featureScoped;
+
+ FeatureFrameworkOperationKind(boolean featureScoped) {
+ this.featureScoped = featureScoped;
+ }
+
+ /** Returns whether this operation must identify one FeatureFramework feature. */
+ public boolean featureScoped() {
+ return featureScoped;
+ }
+}
diff --git a/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationOutcome.java b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationOutcome.java
new file mode 100644
index 0000000..2c06ec0
--- /dev/null
+++ b/featureframework-api/src/main/java/nl/hauntedmc/featureframework/api/observation/FeatureFrameworkOperationOutcome.java
@@ -0,0 +1,14 @@
+package nl.hauntedmc.featureframework.api.observation;
+
+/** Stable, low-cardinality terminal outcome for one observed framework operation. */
+public enum FeatureFrameworkOperationOutcome {
+ SUCCESS,
+ NO_CHANGE,
+ SKIPPED,
+ FAILURE;
+
+ /** Returns whether this outcome represents unsuccessful completion. */
+ public boolean isFailure() {
+ return this == FAILURE;
+ }
+}
diff --git a/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java b/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java
new file mode 100644
index 0000000..e79474c
--- /dev/null
+++ b/featureframework-api/src/test/java/nl/hauntedmc/featureframework/api/ObservationBoundaryTest.java
@@ -0,0 +1,31 @@
+package nl.hauntedmc.featureframework.api;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+class ObservationBoundaryTest {
+
+ @Test
+ void publicApiHasNoTelemetryImplementationDependency() throws IOException {
+ Path sources = Path.of("src", "main", "java");
+ try (var files = Files.walk(sources)) {
+ for (Path file : files.filter(path -> path.toString().endsWith(".java")).toList()) {
+ String source = Files.readString(file);
+ assertFalse(source.contains("io.opentelemetry"), () -> file + " contains OpenTelemetry coupling");
+ assertFalse(
+ source.contains("nl.hauntedmc.observability"),
+ () -> file + " contains HauntedObservability coupling"
+ );
+ assertFalse(
+ source.contains("hauntedobservability"),
+ () -> file + " contains legacy HauntedObservability coupling"
+ );
+ }
+ }
+ }
+}
diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java
new file mode 100644
index 0000000..276e183
--- /dev/null
+++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservations.java
@@ -0,0 +1,190 @@
+package nl.hauntedmc.featureframework.host;
+
+import nl.hauntedmc.featureframework.api.feature.FeatureId;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservation;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationContext;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome;
+
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/** Runtime-local, single-observer dispatcher that keeps instrumentation fail-open. */
+final class FeatureFrameworkObservations {
+
+ private static final Operation NOOP_OPERATION = new Operation(FeatureFrameworkObservation.noop());
+
+ private final FeatureFrameworkObserver observer;
+ private final boolean enabled;
+
+ FeatureFrameworkObservations(FeatureFrameworkObserver observer) {
+ this.observer = Objects.requireNonNull(observer, "observer");
+ this.enabled = observer != FeatureFrameworkObserver.noop();
+ }
+
+ Operation start(FeatureFrameworkOperationKind kind) {
+ if (!enabled) {
+ return NOOP_OPERATION;
+ }
+ return start(FeatureFrameworkOperationContext.host(kind));
+ }
+
+ Operation start(FeatureFrameworkOperationKind kind, FeatureId featureId) {
+ if (!enabled) {
+ return NOOP_OPERATION;
+ }
+ return start(FeatureFrameworkOperationContext.feature(kind, featureId));
+ }
+
+ T observe(
+ FeatureFrameworkOperationKind kind,
+ Supplier work,
+ Function outcome,
+ Function failure
+ ) {
+ Objects.requireNonNull(work, "work");
+ if (!enabled) {
+ return work.get();
+ }
+ return observe(start(kind), work, outcome, failure);
+ }
+
+ T observe(
+ FeatureFrameworkOperationKind kind,
+ FeatureId featureId,
+ Supplier work,
+ Function outcome,
+ Function failure
+ ) {
+ Objects.requireNonNull(work, "work");
+ if (!enabled) {
+ return work.get();
+ }
+ return observe(start(kind, featureId), work, outcome, failure);
+ }
+
+ private T observe(
+ Operation operation,
+ Supplier work,
+ Function outcome,
+ Function failure
+ ) {
+ if (operation.isNoop()) {
+ return work.get();
+ }
+ Objects.requireNonNull(outcome, "outcome");
+ Objects.requireNonNull(failure, "failure");
+ FeatureFrameworkObservationScope scope = operation.openScope();
+ try {
+ try {
+ T result = work.get();
+ completeFromResult(operation, result, outcome, failure);
+ return result;
+ } catch (Throwable throwable) {
+ operation.complete(FeatureFrameworkOperationOutcome.FAILURE, throwable);
+ return throwUnchecked(throwable);
+ }
+ } finally {
+ scope.close();
+ }
+ }
+
+ private static void completeFromResult(
+ Operation operation,
+ T result,
+ Function outcome,
+ Function failure
+ ) {
+ try {
+ operation.complete(
+ Objects.requireNonNull(outcome.apply(result), "observation outcome"),
+ failure.apply(result)
+ );
+ } catch (RuntimeException classificationFailure) {
+ operation.complete(FeatureFrameworkOperationOutcome.FAILURE, classificationFailure);
+ }
+ }
+
+ private Operation start(FeatureFrameworkOperationContext context) {
+ try {
+ FeatureFrameworkObservation observation = observer.start(context);
+ if (observation == null || observation == FeatureFrameworkObservation.noop()) {
+ return NOOP_OPERATION;
+ }
+ return new Operation(observation);
+ } catch (RuntimeException ignored) {
+ return NOOP_OPERATION;
+ }
+ }
+
+ static final class Operation {
+ private final FeatureFrameworkObservation observation;
+ private final AtomicBoolean completed;
+
+ private Operation(FeatureFrameworkObservation observation) {
+ this.observation = observation;
+ this.completed = observation == FeatureFrameworkObservation.noop() ? null : new AtomicBoolean();
+ }
+
+ boolean isNoop() {
+ return completed == null;
+ }
+
+ FeatureFrameworkObservationScope openScope() {
+ if (isNoop()) {
+ return FeatureFrameworkObservationScope.noop();
+ }
+ try {
+ FeatureFrameworkObservationScope scope = observation.openScope();
+ if (scope == null || scope == FeatureFrameworkObservationScope.noop()) {
+ return FeatureFrameworkObservationScope.noop();
+ }
+ return new SafeScope(scope);
+ } catch (RuntimeException ignored) {
+ return FeatureFrameworkObservationScope.noop();
+ }
+ }
+
+ void complete(FeatureFrameworkOperationOutcome outcome, Throwable failure) {
+ Objects.requireNonNull(outcome, "outcome");
+ if (isNoop() || !completed.compareAndSet(false, true)) {
+ return;
+ }
+ try {
+ observation.completed(outcome, failure);
+ } catch (RuntimeException ignored) {
+ // Instrumentation must never replace the framework operation outcome.
+ }
+ }
+ }
+
+ private static final class SafeScope implements FeatureFrameworkObservationScope {
+ private final FeatureFrameworkObservationScope delegate;
+ private final AtomicBoolean closed = new AtomicBoolean();
+
+ private SafeScope(FeatureFrameworkObservationScope delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ try {
+ delegate.close();
+ } catch (RuntimeException ignored) {
+ // Context cleanup must never alter FeatureFramework behavior.
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static T throwUnchecked(Throwable failure) throws E {
+ throw (E) failure;
+ }
+}
diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java
index 630b588..df7102a 100644
--- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java
+++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHost.java
@@ -4,40 +4,44 @@
import nl.hauntedmc.featureframework.api.RuntimeState;
import nl.hauntedmc.featureframework.api.feature.FeatureCatalog;
import nl.hauntedmc.featureframework.api.feature.FeatureId;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome;
import nl.hauntedmc.featureframework.api.service.CapabilityRegistry;
import nl.hauntedmc.featureframework.config.FeatureConfigurationRoot;
import nl.hauntedmc.featureframework.feature.LifecycleFeature;
-import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition;
+import nl.hauntedmc.featureframework.feature.stateful.SnapshotState;
import nl.hauntedmc.featureframework.loader.FeatureLoadOrderResolver;
+import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition;
import nl.hauntedmc.featureframework.operation.FeatureOperationCoordinator;
import nl.hauntedmc.featureframework.operation.disable.FeatureDisableResponse;
import nl.hauntedmc.featureframework.operation.enable.FeatureEnableResponse;
import nl.hauntedmc.featureframework.operation.reload.FeatureGraphReloadResult;
import nl.hauntedmc.featureframework.operation.reload.FeatureGraphReloader;
import nl.hauntedmc.featureframework.operation.reload.FeatureReloadResponse;
-import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse;
import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetPreview;
import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetRequest;
import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetResponse;
import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetResult;
import nl.hauntedmc.featureframework.operation.reset.FeatureResetRollbackOutcome;
import nl.hauntedmc.featureframework.operation.reset.FeatureResetRuntimeOutcome;
+import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse;
import nl.hauntedmc.featureframework.runtime.FeatureRuntime;
import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry;
-import nl.hauntedmc.featureframework.feature.stateful.SnapshotState;
import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
-import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletionStage;
+import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
-import java.util.function.Consumer;
/**
* Complete platform-neutral host for a collection of managed features.
@@ -62,6 +66,7 @@ final class FeatureHost, C extends FeatureHostC
private final FeatureInventory inventory;
private final FeatureInstanceController controller;
private final FeatureFileResetStorage resetStorage;
+ private final FeatureFrameworkObservations observations;
private boolean startAttempted;
private FeatureHost(Builder builder) {
@@ -80,10 +85,12 @@ private FeatureHost(Builder builder) {
? configuration::reloadConfig
: builder.reloadHostResources;
logger = Objects.requireNonNull(builder.logger, "logger");
+ observations = new FeatureFrameworkObservations(builder.observer);
resetStorage = new FeatureFileResetStorage(configuration.files(), logger);
inventory = new FeatureInventory<>(
capabilityNamespace, runtime, configuration, collection, pluginAvailable, logger);
- controller = new FeatureInstanceController<>(inventory, runtime, configuration, contextFactory, logger);
+ controller = new FeatureInstanceController<>(
+ inventory, runtime, configuration, contextFactory, logger, observations);
}
public static , C extends FeatureHostContext>
@@ -104,6 +111,18 @@ public void start() {
}
private synchronized void startLocked() {
+ observations.observe(
+ FeatureFrameworkOperationKind.HOST_START,
+ () -> {
+ startLockedUnobserved();
+ return null;
+ },
+ ignored -> FeatureFrameworkOperationOutcome.SUCCESS,
+ ignored -> null
+ );
+ }
+
+ private void startLockedUnobserved() {
if (startAttempted) throw new IllegalStateException(hostName + " has already been started");
startAttempted = true;
runtime.markStarting();
@@ -129,11 +148,18 @@ private void initializeFeatures() {
}
public FeatureEnableResponse enable(FeatureId id) {
- return enableLockedId(Objects.requireNonNull(id, "id").value());
+ FeatureId featureId = Objects.requireNonNull(id, "id");
+ return runtime.lifecycle().callExclusive(() -> observations.observe(
+ FeatureFrameworkOperationKind.FEATURE_ENABLE,
+ featureId,
+ () -> enableLockedId(featureId.value()),
+ FeatureHost::enableOutcome,
+ ignored -> null
+ ));
}
private FeatureEnableResponse enableLockedId(String featureName) {
- return runtime.lifecycle().callExclusive(() -> FeatureOperationCoordinator.enable(
+ return FeatureOperationCoordinator.enable(
featureName,
inventory::resolveFeatureKey,
key -> inventory.registry().getAvailableFeature(key) != null,
@@ -143,11 +169,18 @@ private FeatureEnableResponse enableLockedId(String featureName) {
this::persistEnabled,
controller::loadFeature,
afterGraphMutation
- ));
+ );
}
public FeatureDisableResponse disable(FeatureId id) {
- return runtime.lifecycle().callExclusive(() -> disableFeatureLocked(Objects.requireNonNull(id, "id").value()));
+ FeatureId featureId = Objects.requireNonNull(id, "id");
+ return runtime.lifecycle().callExclusive(() -> observations.observe(
+ FeatureFrameworkOperationKind.FEATURE_DISABLE,
+ featureId,
+ () -> disableFeatureLocked(featureId.value()),
+ FeatureHost::disableOutcome,
+ ignored -> null
+ ));
}
private FeatureDisableResponse disableFeatureLocked(String featureName) {
@@ -165,27 +198,47 @@ private FeatureDisableResponse disableFeatureLocked(String featureName) {
}
public FeatureSoftReloadResponse softReload(FeatureId id) {
- String featureName = Objects.requireNonNull(id, "id").value();
- return runtime.lifecycle().callExclusive(() -> FeatureOperationCoordinator.softReload(
- featureName,
- inventory::resolveFeatureKey,
- inventory.registry()::isFeatureLoaded,
- key -> {
- F feature = controller.loadedFeature(key);
- feature.context().prepare(feature);
- return feature.applyConfiguration();
- },
- this::recreateLocked
+ FeatureId featureId = Objects.requireNonNull(id, "id");
+ return runtime.lifecycle().callExclusive(() -> observations.observe(
+ FeatureFrameworkOperationKind.FEATURE_SOFT_RELOAD,
+ featureId,
+ () -> FeatureOperationCoordinator.softReload(
+ featureId.value(),
+ inventory::resolveFeatureKey,
+ inventory.registry()::isFeatureLoaded,
+ key -> {
+ F feature = controller.loadedFeature(key);
+ feature.context().prepare(feature);
+ return feature.applyConfiguration();
+ },
+ this::recreateLocked
+ ),
+ FeatureHost::softReloadOutcome,
+ ignored -> null
));
}
public FeatureReloadResponse recreate(FeatureId id) {
- return runtime.lifecycle().callExclusive(() -> recreateLocked(Objects.requireNonNull(id, "id").value()));
+ FeatureId featureId = Objects.requireNonNull(id, "id");
+ return runtime.lifecycle().callExclusive(() -> observations.observe(
+ FeatureFrameworkOperationKind.FEATURE_RECREATE,
+ featureId,
+ () -> recreateLocked(featureId.value()),
+ FeatureHost::reloadOutcome,
+ ignored -> null
+ ));
}
/** Reloads host configuration and transactionally reconciles every configured feature. */
public FeatureGraphReloadResult reloadGraph() {
- return runtime.lifecycle().callExclusive(this::reloadLocked);
+ return runtime.lifecycle().callExclusive(() -> observations.observe(
+ FeatureFrameworkOperationKind.GRAPH_RELOAD,
+ this::reloadLocked,
+ result -> result.success()
+ ? FeatureFrameworkOperationOutcome.SUCCESS
+ : FeatureFrameworkOperationOutcome.FAILURE,
+ result -> result.failure().orElse(null)
+ ));
}
public FeatureFileResetPreview previewFileReset(FeatureId id, FeatureFileResetRequest request) {
@@ -217,9 +270,15 @@ private FeatureFileResetPreview previewFileResetLocked(String requested, Feature
}
public FeatureFileResetResponse resetFiles(FeatureId id, FeatureFileResetRequest request) {
- Objects.requireNonNull(id, "id");
+ FeatureId featureId = Objects.requireNonNull(id, "id");
Objects.requireNonNull(request, "request");
- return runtime.lifecycle().callExclusive(() -> resetFilesLocked(id.value(), request));
+ return runtime.lifecycle().callExclusive(() -> observations.observe(
+ FeatureFrameworkOperationKind.FILE_RESET,
+ featureId,
+ () -> resetFilesLocked(featureId.value(), request),
+ FeatureHost::resetOutcome,
+ response -> response.failure().orElse(null)
+ ));
}
boolean reloadFeatureLocalization(FeatureId id, Consumer reload) {
@@ -446,11 +505,27 @@ public void stop() {
}
private synchronized void stopLocked() {
- if (runtime.state() == RuntimeState.STOPPED) return;
- runtime.markStopping();
- Throwable failure = unloadAll();
- runtime.markStopped(failure);
- if (failure != null) logger.error(hostName + " shutdown completed with failures.", failure);
+ FeatureFrameworkObservations.Operation observation = observations.start(FeatureFrameworkOperationKind.HOST_STOP);
+ FeatureFrameworkObservationScope scope = observation.openScope();
+ try {
+ if (runtime.state() == RuntimeState.STOPPED) {
+ observation.complete(FeatureFrameworkOperationOutcome.NO_CHANGE, null);
+ return;
+ }
+ runtime.markStopping();
+ Throwable failure = unloadAll();
+ runtime.markStopped(failure);
+ if (failure != null) logger.error(hostName + " shutdown completed with failures.", failure);
+ observation.complete(
+ failure == null ? FeatureFrameworkOperationOutcome.SUCCESS : FeatureFrameworkOperationOutcome.FAILURE,
+ failure
+ );
+ } catch (Throwable failure) {
+ observation.complete(FeatureFrameworkOperationOutcome.FAILURE, failure);
+ throwUnchecked(failure);
+ } finally {
+ scope.close();
+ }
}
private Throwable unloadAll() {
@@ -516,6 +591,7 @@ public static final class Builder<
private Runnable clearScopes = () -> { };
private Runnable reloadHostResources;
private FrameworkLogger logger = FrameworkLogger.noop();
+ private FeatureFrameworkObserver observer = FeatureFrameworkObserver.noop();
private Builder(
String hostName,
@@ -563,12 +639,59 @@ public Builder logger(FrameworkLogger value) {
return this;
}
+ public Builder observer(FeatureFrameworkObserver value) {
+ observer = Objects.requireNonNull(value, "observer");
+ return this;
+ }
public FeatureHost build() {
return new FeatureHost<>(this);
}
}
+ private static FeatureFrameworkOperationOutcome enableOutcome(FeatureEnableResponse response) {
+ return switch (response.result()) {
+ case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS;
+ case ALREADY_LOADED -> FeatureFrameworkOperationOutcome.NO_CHANGE;
+ case NOT_FOUND, MISSING_PLUGIN_DEPENDENCY, MISSING_FEATURE_DEPENDENCY ->
+ FeatureFrameworkOperationOutcome.SKIPPED;
+ case FAILED -> FeatureFrameworkOperationOutcome.FAILURE;
+ };
+ }
+
+ private static FeatureFrameworkOperationOutcome disableOutcome(FeatureDisableResponse response) {
+ return switch (response.result()) {
+ case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS;
+ case NOT_LOADED -> FeatureFrameworkOperationOutcome.NO_CHANGE;
+ case FAILED -> FeatureFrameworkOperationOutcome.FAILURE;
+ };
+ }
+
+ private static FeatureFrameworkOperationOutcome reloadOutcome(FeatureReloadResponse response) {
+ return switch (response.result()) {
+ case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS;
+ case NOT_LOADED -> FeatureFrameworkOperationOutcome.SKIPPED;
+ case FAILED -> FeatureFrameworkOperationOutcome.FAILURE;
+ };
+ }
+
+ private static FeatureFrameworkOperationOutcome softReloadOutcome(FeatureSoftReloadResponse response) {
+ return switch (response.result()) {
+ case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS;
+ case NOT_LOADED -> FeatureFrameworkOperationOutcome.SKIPPED;
+ case FAILED -> FeatureFrameworkOperationOutcome.FAILURE;
+ };
+ }
+
+ private static FeatureFrameworkOperationOutcome resetOutcome(FeatureFileResetResponse response) {
+ return switch (response.result()) {
+ case SUCCESS -> FeatureFrameworkOperationOutcome.SUCCESS;
+ case NOT_FOUND, HOST_UNAVAILABLE, UNSAFE_TARGET -> FeatureFrameworkOperationOutcome.SKIPPED;
+ case QUIESCE_FAILED, BACKUP_FAILED, REGENERATION_FAILED, RESTART_FAILED, ROLLBACK_FAILED ->
+ FeatureFrameworkOperationOutcome.FAILURE;
+ };
+ }
+
private static String requireText(String value, String field) {
String clean = Objects.requireNonNull(value, field).trim();
if (clean.isEmpty()) throw new IllegalArgumentException(field + " must not be blank");
diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java
index 902fdfd..326bd86 100644
--- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java
+++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureHostComposition.java
@@ -1,8 +1,9 @@
package nl.hauntedmc.featureframework.host;
-import nl.hauntedmc.featureframework.api.feature.FeatureId;
-import nl.hauntedmc.featureframework.api.feature.FeatureCatalog;
import nl.hauntedmc.featureframework.api.RuntimeState;
+import nl.hauntedmc.featureframework.api.feature.FeatureCatalog;
+import nl.hauntedmc.featureframework.api.feature.FeatureId;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver;
import nl.hauntedmc.featureframework.api.service.CapabilityRegistry;
import nl.hauntedmc.featureframework.config.FeatureConfigurationRoot;
import nl.hauntedmc.featureframework.feature.LifecycleFeature;
@@ -13,16 +14,16 @@
import nl.hauntedmc.featureframework.operation.enable.FeatureEnableResponse;
import nl.hauntedmc.featureframework.operation.reload.FeatureGraphReloadResult;
import nl.hauntedmc.featureframework.operation.reload.FeatureReloadResponse;
-import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse;
import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetPreview;
import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetRequest;
import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetResponse;
+import nl.hauntedmc.featureframework.operation.softreload.FeatureSoftReloadResponse;
import nl.hauntedmc.featureframework.runtime.FeatureRuntime;
import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry;
import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger;
-import java.util.Objects;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
@@ -63,6 +64,46 @@ public FeatureHostComposition(
Runnable reloadLocalization,
Runnable afterHostResourcesReload,
FrameworkLogger logger
+ ) {
+ this(
+ hostName,
+ version,
+ capabilityNamespace,
+ runtime,
+ configuration,
+ features,
+ configFactory,
+ localizationFactory,
+ loggerFactory,
+ resourcesFactory,
+ contextAssembler,
+ pluginAvailable,
+ afterGraphMutation,
+ reloadLocalization,
+ afterHostResourcesReload,
+ logger,
+ FeatureFrameworkObserver.noop()
+ );
+ }
+
+ public FeatureHostComposition(
+ String hostName,
+ V version,
+ String capabilityNamespace,
+ FeatureRuntime runtime,
+ FeatureConfigurationRoot> configuration,
+ FeatureCollection features,
+ Function configFactory,
+ Function localizationFactory,
+ Function loggerFactory,
+ Function, ? extends R> resourcesFactory,
+ FeatureScopeFactory.ContextAssembler contextAssembler,
+ Predicate pluginAvailable,
+ Runnable afterGraphMutation,
+ Runnable reloadLocalization,
+ Runnable afterHostResourcesReload,
+ FrameworkLogger logger,
+ FeatureFrameworkObserver observer
) {
Objects.requireNonNull(runtime, "runtime");
Objects.requireNonNull(configuration, "configuration");
@@ -80,6 +121,7 @@ public FeatureHostComposition(
.clearScopes(scopes::clear)
.reloadHostResources(reloadResources)
.logger(Objects.requireNonNull(logger, "logger"))
+ .observer(Objects.requireNonNull(observer, "observer"))
.build();
}
diff --git a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java
index 9244a3d..f56168c 100644
--- a/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java
+++ b/featureframework-core/src/main/java/nl/hauntedmc/featureframework/host/FeatureInstanceController.java
@@ -2,24 +2,27 @@
import nl.hauntedmc.featureframework.api.feature.FeatureId;
import nl.hauntedmc.featureframework.api.feature.FeatureState;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome;
import nl.hauntedmc.featureframework.config.FeatureConfigurationRoot;
+import nl.hauntedmc.featureframework.config.FeatureStoragePaths;
import nl.hauntedmc.featureframework.feature.LifecycleFeature;
import nl.hauntedmc.featureframework.feature.stateful.FeatureReloadState;
import nl.hauntedmc.featureframework.feature.stateful.SnapshotState;
import nl.hauntedmc.featureframework.loader.FeatureDependencyManager;
-import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition;
import nl.hauntedmc.featureframework.loader.FeatureGraphLifecycle;
import nl.hauntedmc.featureframework.loader.FeatureGraphReloadTransaction;
import nl.hauntedmc.featureframework.loader.FeatureRegistry;
import nl.hauntedmc.featureframework.loader.FeatureStartupCoordinator;
+import nl.hauntedmc.featureframework.loader.ResolvedFeatureDefinition;
import nl.hauntedmc.featureframework.operation.reload.FeatureReloadResponse;
import nl.hauntedmc.featureframework.operation.reload.FeatureReloadResult;
import nl.hauntedmc.featureframework.operation.reset.FeatureFileResetRequest;
-import nl.hauntedmc.featureframework.config.FeatureStoragePaths;
-import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMap;
-import nl.hauntedmc.featureframework.toolkit.io.localization.MessageMap;
import nl.hauntedmc.featureframework.runtime.FeatureRuntime;
import nl.hauntedmc.featureframework.service.DefaultCapabilityRegistry;
+import nl.hauntedmc.featureframework.toolkit.io.config.ConfigMap;
+import nl.hauntedmc.featureframework.toolkit.io.localization.MessageMap;
import nl.hauntedmc.featureframework.toolkit.log.FrameworkLogger;
import java.util.LinkedHashMap;
@@ -47,19 +50,22 @@ final class FeatureInstanceController, C extends F
private final Set preparationFailures = new LinkedHashSet<>();
private final Map defaults = new LinkedHashMap<>();
private final FeatureDependencyManager dependencyManager;
+ private final FeatureFrameworkObservations observations;
FeatureInstanceController(
FeatureInventory inventory,
FeatureRuntime runtime,
FeatureConfigurationRoot> configuration,
Function, C> contextFactory,
- FrameworkLogger logger
+ FrameworkLogger logger,
+ FeatureFrameworkObservations observations
) {
this.inventory = inventory;
this.runtime = runtime;
this.configuration = configuration;
this.contextFactory = contextFactory;
this.logger = logger;
+ this.observations = observations;
registry = inventory.registry();
dependencyManager = new FeatureDependencyManager(
inventory::resolveFeatureKey,
@@ -185,41 +191,71 @@ FeatureReloadResponse reloadFeature(String featureName) {
private boolean loadFeature(String featureName, SnapshotState reloadState) {
String key = inventory.resolveFeatureKey(featureName);
- if (key == null || registry.isFeatureLoaded(key) || preparationFailures.contains(key)
- || inventory.hasStorageFailure(key)) return false;
- ResolvedFeatureDefinition descriptor = registry.getAvailableFeature(key);
- if (descriptor == null) return false;
+ if (key == null) return false;
- boolean enabled = configuration.isFeatureEnabled(key);
- runtime.mutableFeatureCatalog().setConfiguredEnabled(FeatureId.of(key), enabled);
- if (!enabled || !inventory.missingPluginDependencies(key).isEmpty()
- || !dependencyManager.areDependenciesMet(key)) return false;
-
- return FeatureStartupCoordinator.start(
- reloadState,
- () -> contextFactory.apply(descriptor),
- descriptor::create,
- feature -> {
- captureDefaults(key, feature);
- feature.context().prepare(feature);
- },
- LifecycleFeature::initialize,
- feature -> feature.context().activateServices(),
- feature -> registry.registerLoadedFeature(key, feature),
- () -> runtime.mutableFeatureCatalog().transition(FeatureId.of(key), FeatureState.STARTING),
- () -> {
- runtime.mutableFeatureCatalog().setUnavailableDependencies(FeatureId.of(key), Set.of());
- runtime.mutableFeatureCatalog().transition(FeatureId.of(key), FeatureState.ACTIVE);
- logger.info("Feature loaded: " + key);
- },
- failure -> {
- runtime.mutableFeatureCatalog().fail(FeatureId.of(key), "startup", failure);
- logger.error("Feature '" + key + "' failed to start.", failure);
- },
- LifecycleFeature::cleanup,
- FeatureHostContext::cleanup,
- () -> registry.deregisterLoadedFeature(key)
+ FeatureId featureId = FeatureId.of(key);
+ FeatureFrameworkObservations.Operation observation = observations.start(
+ FeatureFrameworkOperationKind.FEATURE_LOAD,
+ featureId
);
+ FeatureFrameworkObservationScope scope = observation.openScope();
+ try {
+ if (registry.isFeatureLoaded(key) || preparationFailures.contains(key) || inventory.hasStorageFailure(key)) {
+ observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null);
+ return false;
+ }
+ ResolvedFeatureDefinition descriptor = registry.getAvailableFeature(key);
+ if (descriptor == null) {
+ observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null);
+ return false;
+ }
+
+ boolean enabled = configuration.isFeatureEnabled(key);
+ runtime.mutableFeatureCatalog().setConfiguredEnabled(featureId, enabled);
+ if (!enabled || !inventory.missingPluginDependencies(key).isEmpty()
+ || !dependencyManager.areDependenciesMet(key)) {
+ observation.complete(FeatureFrameworkOperationOutcome.SKIPPED, null);
+ return false;
+ }
+
+ Throwable[] startupFailure = observation.isNoop() ? null : new Throwable[1];
+ boolean loaded = FeatureStartupCoordinator.start(
+ reloadState,
+ () -> contextFactory.apply(descriptor),
+ descriptor::create,
+ feature -> {
+ captureDefaults(key, feature);
+ feature.context().prepare(feature);
+ },
+ LifecycleFeature::initialize,
+ feature -> feature.context().activateServices(),
+ feature -> registry.registerLoadedFeature(key, feature),
+ () -> runtime.mutableFeatureCatalog().transition(featureId, FeatureState.STARTING),
+ () -> {
+ runtime.mutableFeatureCatalog().setUnavailableDependencies(featureId, Set.of());
+ runtime.mutableFeatureCatalog().transition(featureId, FeatureState.ACTIVE);
+ logger.info("Feature loaded: " + key);
+ },
+ failure -> {
+ if (startupFailure != null) startupFailure[0] = failure;
+ runtime.mutableFeatureCatalog().fail(featureId, "startup", failure);
+ logger.error("Feature '" + key + "' failed to start.", failure);
+ },
+ LifecycleFeature::cleanup,
+ FeatureHostContext::cleanup,
+ () -> registry.deregisterLoadedFeature(key)
+ );
+ observation.complete(
+ loaded ? FeatureFrameworkOperationOutcome.SUCCESS : FeatureFrameworkOperationOutcome.FAILURE,
+ startupFailure == null ? null : startupFailure[0]
+ );
+ return loaded;
+ } catch (Throwable failure) {
+ observation.complete(FeatureFrameworkOperationOutcome.FAILURE, failure);
+ return throwUnchecked(failure);
+ } finally {
+ scope.close();
+ }
}
List buildReloadOrder(String root) {
@@ -293,5 +329,10 @@ private static Object copyValue(Object value) {
return value;
}
+ @SuppressWarnings("unchecked")
+ private static T throwUnchecked(Throwable failure) throws E {
+ throw (E) failure;
+ }
+
private record FeatureDefaults(ConfigMap config, MessageMap messages) { }
}
diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/architecture/SharedBoundaryTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/architecture/SharedBoundaryTest.java
index 3820245..550c541 100644
--- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/architecture/SharedBoundaryTest.java
+++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/architecture/SharedBoundaryTest.java
@@ -21,6 +21,15 @@ void sharedSourcesHaveNoPlatformKnowledge() throws IOException {
);
}
+ @Test
+ void sharedSourcesHaveNoTelemetryImplementationKnowledge() throws IOException {
+ assertSourcesExclude(
+ "io.opentelemetry",
+ "nl.hauntedmc.observability",
+ "hauntedobservability"
+ );
+ }
+
@Test
void hostFacadeDoesNotReabsorbLowLevelGraphMechanics() throws IOException {
String source = Files.readString(Path.of(
diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java
new file mode 100644
index 0000000..bc6c6a0
--- /dev/null
+++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureFrameworkObservationsTest.java
@@ -0,0 +1,231 @@
+package nl.hauntedmc.featureframework.host;
+
+import nl.hauntedmc.featureframework.api.feature.FeatureId;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservation;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservationScope;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationContext;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.RecordComponent;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FeatureFrameworkObservationsTest {
+
+ @Test
+ void contextContainsOnlyBoundedOperationAndOptionalFeatureId() {
+ List components = Arrays.stream(FeatureFrameworkOperationContext.class.getRecordComponents())
+ .map(RecordComponent::getName)
+ .toList();
+ assertEquals(List.of("operation", "featureId"), components);
+
+ var host = FeatureFrameworkOperationContext.host(FeatureFrameworkOperationKind.HOST_START);
+ assertTrue(host.featureId().isEmpty());
+
+ FeatureId featureId = FeatureId.of("lottery");
+ var feature = FeatureFrameworkOperationContext.feature(
+ FeatureFrameworkOperationKind.FEATURE_LOAD,
+ featureId
+ );
+ assertEquals(featureId, feature.featureId().orElseThrow());
+
+ assertThrows(IllegalArgumentException.class, () -> new FeatureFrameworkOperationContext(
+ FeatureFrameworkOperationKind.FEATURE_LOAD,
+ java.util.Optional.empty()
+ ));
+ assertThrows(IllegalArgumentException.class, () -> new FeatureFrameworkOperationContext(
+ FeatureFrameworkOperationKind.HOST_STOP,
+ java.util.Optional.of(featureId)
+ ));
+ }
+
+ @Test
+ void observerFailuresAndScopeFailuresCannotChangeFrameworkWork() {
+ AtomicInteger starts = new AtomicInteger();
+ AtomicInteger completions = new AtomicInteger();
+ AtomicBoolean scopeClosed = new AtomicBoolean();
+ FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context -> {
+ starts.incrementAndGet();
+ return new FeatureFrameworkObservation() {
+ @Override
+ public FeatureFrameworkObservationScope openScope() {
+ return () -> {
+ scopeClosed.set(true);
+ throw new IllegalStateException("scope close failure");
+ };
+ }
+
+ @Override
+ public void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure) {
+ completions.incrementAndGet();
+ throw new IllegalStateException("completion failure");
+ }
+ };
+ });
+
+ assertEquals("ok", observations.observe(
+ FeatureFrameworkOperationKind.FEATURE_ENABLE,
+ FeatureId.of("lottery"),
+ () -> "ok",
+ ignored -> FeatureFrameworkOperationOutcome.SUCCESS,
+ ignored -> null
+ ));
+ assertEquals(1, starts.get());
+ assertEquals(1, completions.get());
+ assertTrue(scopeClosed.get());
+ }
+
+ @Test
+ void observationCompletesExactlyOnce() {
+ AtomicInteger completions = new AtomicInteger();
+ FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context ->
+ recordingObservation(new AtomicReference<>(), completions));
+
+ FeatureFrameworkObservations.Operation operation = observations.start(
+ FeatureFrameworkOperationKind.FEATURE_LOAD,
+ FeatureId.of("lottery")
+ );
+ operation.complete(FeatureFrameworkOperationOutcome.SUCCESS, null);
+ operation.complete(FeatureFrameworkOperationOutcome.FAILURE, new IllegalStateException());
+
+ assertEquals(1, completions.get());
+ }
+
+ @Test
+ void observerStartFailureFallsBackToNoop() {
+ FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context -> {
+ throw new IllegalStateException("adapter failure");
+ });
+
+ assertEquals(42, observations.observe(
+ FeatureFrameworkOperationKind.GRAPH_RELOAD,
+ () -> 42,
+ ignored -> FeatureFrameworkOperationOutcome.SUCCESS,
+ ignored -> null
+ ));
+ }
+
+ @Test
+ void disabledAndFilteredObserversSkipTerminalClassification() {
+ FeatureFrameworkObservations disabled = new FeatureFrameworkObservations(
+ nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver.noop());
+ FeatureFrameworkObservations filtered = new FeatureFrameworkObservations(
+ context -> FeatureFrameworkObservation.noop());
+
+ assertEquals("disabled", disabled.observe(
+ FeatureFrameworkOperationKind.GRAPH_RELOAD,
+ () -> "disabled",
+ ignored -> { throw new AssertionError("disabled operation was classified"); },
+ ignored -> { throw new AssertionError("disabled failure was inspected"); }
+ ));
+ assertEquals("filtered", filtered.observe(
+ FeatureFrameworkOperationKind.GRAPH_RELOAD,
+ () -> "filtered",
+ ignored -> { throw new AssertionError("filtered operation was classified"); },
+ ignored -> { throw new AssertionError("filtered failure was inspected"); }
+ ));
+ }
+
+ @Test
+ void classificationFailureCannotChangeFrameworkWork() {
+ AtomicReference outcome = new AtomicReference<>();
+ AtomicReference observedFailure = new AtomicReference<>();
+ FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context ->
+ new FeatureFrameworkObservation() {
+ @Override
+ public void completed(FeatureFrameworkOperationOutcome value, Throwable failure) {
+ outcome.set(value);
+ observedFailure.set(failure);
+ }
+ });
+
+ assertEquals("ok", observations.observe(
+ FeatureFrameworkOperationKind.GRAPH_RELOAD,
+ () -> "ok",
+ ignored -> { throw new IllegalStateException("classification failure"); },
+ ignored -> null
+ ));
+ assertEquals(FeatureFrameworkOperationOutcome.FAILURE, outcome.get());
+ assertEquals("classification failure", observedFailure.get().getMessage());
+ }
+
+ @Test
+ void scopeIsActiveAroundActualWork() {
+ AtomicBoolean active = new AtomicBoolean();
+ AtomicBoolean closed = new AtomicBoolean();
+ FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context ->
+ new FeatureFrameworkObservation() {
+ @Override
+ public FeatureFrameworkObservationScope openScope() {
+ active.set(true);
+ return () -> {
+ active.set(false);
+ closed.set(true);
+ };
+ }
+
+ @Override
+ public void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure) {
+ }
+ });
+
+ assertTrue(observations.observe(
+ FeatureFrameworkOperationKind.HOST_START,
+ active::get,
+ ignored -> FeatureFrameworkOperationOutcome.SUCCESS,
+ ignored -> null
+ ));
+ assertFalse(active.get());
+ assertTrue(closed.get());
+ }
+
+ @Test
+ void workFailureIsReportedAndRethrownUnchanged() {
+ AtomicReference outcome = new AtomicReference<>();
+ AtomicReference observedFailure = new AtomicReference<>();
+ FeatureFrameworkObservations observations = new FeatureFrameworkObservations(context ->
+ new FeatureFrameworkObservation() {
+ @Override
+ public void completed(FeatureFrameworkOperationOutcome value, Throwable failure) {
+ outcome.set(value);
+ observedFailure.set(failure);
+ }
+ });
+ IllegalStateException failure = new IllegalStateException("boom");
+
+ IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> observations.observe(
+ FeatureFrameworkOperationKind.FEATURE_RECREATE,
+ FeatureId.of("lottery"),
+ () -> { throw failure; },
+ ignored -> FeatureFrameworkOperationOutcome.SUCCESS,
+ ignored -> null
+ ));
+
+ assertTrue(thrown == failure);
+ assertEquals(FeatureFrameworkOperationOutcome.FAILURE, outcome.get());
+ assertTrue(observedFailure.get() == failure);
+ }
+
+ private static FeatureFrameworkObservation recordingObservation(
+ AtomicReference outcome,
+ AtomicInteger completions
+ ) {
+ return new FeatureFrameworkObservation() {
+ @Override
+ public void completed(FeatureFrameworkOperationOutcome value, Throwable failure) {
+ outcome.set(value);
+ completions.incrementAndGet();
+ }
+ };
+ }
+}
diff --git a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureHostTest.java b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureHostTest.java
index 7e2e107..2e98063 100644
--- a/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureHostTest.java
+++ b/featureframework-core/src/test/java/nl/hauntedmc/featureframework/host/FeatureHostTest.java
@@ -4,6 +4,10 @@
import nl.hauntedmc.featureframework.api.feature.FeatureId;
import nl.hauntedmc.featureframework.api.feature.FeatureRole;
import nl.hauntedmc.featureframework.api.feature.FeatureState;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObservation;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationContext;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationKind;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkOperationOutcome;
import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration;
import nl.hauntedmc.featureframework.config.FeatureConfigHandler;
import nl.hauntedmc.featureframework.lifecycle.FeatureLifecycleResources;
@@ -20,8 +24,8 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
-import java.nio.file.Path;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -41,6 +45,8 @@ void hostsCollectionAndReloadsProviderWithDependent() {
ConsumerFeature.starts.set(0);
List startupSequence = new ArrayList<>();
List transitions = new ArrayList<>();
+ List observedContexts = new ArrayList<>();
+ List observedOutcomes = new ArrayList<>();
FrameworkLogger logger = FrameworkLogger.noop();
ConfigService configService = new ConfigService(
temporaryDirectory, logger, getClass().getClassLoader());
@@ -70,6 +76,15 @@ void hostsCollectionAndReloadsProviderWithDependent() {
"test-host", "1.0.0", "test", runtime, configuration, features)
.contextFactory(descriptor -> context(
descriptor, configuration, localization, runtime, logger))
+ .observer(context -> {
+ observedContexts.add(context);
+ return new FeatureFrameworkObservation() {
+ @Override
+ public void completed(FeatureFrameworkOperationOutcome outcome, Throwable failure) {
+ observedOutcomes.add(outcome);
+ }
+ };
+ })
.logger(logger)
.build();
host.features().subscribe(snapshot -> transitions.add(snapshot.state()));
@@ -85,14 +100,25 @@ void hostsCollectionAndReloadsProviderWithDependent() {
host.features().find(FeatureId.of("Consumer")).orElseThrow().metadata().roles());
assertTrue(host.features().snapshot().stream()
.allMatch(snapshot -> snapshot.state() == FeatureState.ACTIVE));
+ assertEquals(FeatureFrameworkOperationKind.HOST_START, observedContexts.getFirst().operation());
+ assertEquals(
+ List.of("provider", "consumer"),
+ observedContexts.stream()
+ .filter(context -> context.operation() == FeatureFrameworkOperationKind.FEATURE_LOAD)
+ .map(context -> context.featureId().orElseThrow().value())
+ .toList()
+ );
var missingPreview = host.previewFileReset(FeatureId.of("Missing"), FeatureFileResetRequest.config());
assertFalse(missingPreview.valid());
assertTrue(missingPreview.feature().isBlank());
assertEquals("hello-1", host.capabilities().reference(GreetingApi.class).require().greeting());
long firstGeneration = host.capabilities().reference(GreetingApi.class).generation().orElseThrow();
+ int beforeRecreate = observedContexts.size();
assertTrue(host.recreate(FeatureId.of("Provider")).success());
+ assertEquals(FeatureFrameworkOperationKind.FEATURE_RECREATE, observedContexts.get(beforeRecreate).operation());
+ assertEquals(FeatureFrameworkOperationKind.FEATURE_LOAD, observedContexts.get(beforeRecreate + 1).operation());
assertEquals(2, ProviderFeature.starts.get());
assertEquals(2, ConsumerFeature.starts.get());
assertEquals("hello-2", host.capabilities().reference(GreetingApi.class).require().greeting());
@@ -129,11 +155,26 @@ void hostsCollectionAndReloadsProviderWithDependent() {
assertTrue(Files.notExists(providerDirectory.resolve("messages_EN.yml")));
assertTrue(Files.notExists(providerDirectory.resolve("messages_old-LANG.yml")));
+ assertTrue(host.disable(FeatureId.of("Consumer")).success());
+ assertTrue(host.enable(FeatureId.of("Consumer")).success());
+ assertTrue(host.softReload(FeatureId.of("Consumer")).success());
host.stop();
assertEquals(RuntimeState.STOPPED, host.state());
assertTrue(host.features().snapshot().stream()
.allMatch(snapshot -> snapshot.state() == FeatureState.DISABLED));
+ assertTrue(observedContexts.stream().anyMatch(
+ context -> context.operation() == FeatureFrameworkOperationKind.GRAPH_RELOAD));
+ assertTrue(observedContexts.stream().anyMatch(
+ context -> context.operation() == FeatureFrameworkOperationKind.FILE_RESET));
+ assertTrue(observedContexts.stream().anyMatch(
+ context -> context.operation() == FeatureFrameworkOperationKind.FEATURE_DISABLE));
+ assertTrue(observedContexts.stream().anyMatch(
+ context -> context.operation() == FeatureFrameworkOperationKind.FEATURE_ENABLE));
+ assertTrue(observedContexts.stream().anyMatch(
+ context -> context.operation() == FeatureFrameworkOperationKind.FEATURE_SOFT_RELOAD));
+ assertEquals(FeatureFrameworkOperationKind.HOST_STOP, observedContexts.getLast().operation());
+ assertTrue(observedOutcomes.stream().noneMatch(FeatureFrameworkOperationOutcome::isFailure));
}
private static TestContext context(
diff --git a/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java b/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java
index a46dad6..c658393 100644
--- a/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java
+++ b/featureframework-paper/src/main/java/nl/hauntedmc/featureframework/paper/host/PaperFeatureHost.java
@@ -4,6 +4,7 @@
import nl.hauntedmc.featureframework.api.RuntimeState;
import nl.hauntedmc.featureframework.api.feature.FeatureCatalog;
import nl.hauntedmc.featureframework.api.feature.FeatureId;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver;
import nl.hauntedmc.featureframework.api.service.CapabilityRegistry;
import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration;
import nl.hauntedmc.featureframework.config.FeatureConfigHandler;
@@ -101,7 +102,8 @@ private PaperFeatureHost(Builder builder) {
() -> CommandSync.apply(builder.plugin),
localization::reloadLocalization,
builder.afterHostResourcesReload,
- frameworkLogger
+ frameworkLogger,
+ builder.observer
);
}
@@ -200,6 +202,7 @@ public static final class Builder
{
private PaperMessageDecorator messageDecorator = PaperMessageDecorator.identity();
private String overwriteCommandConflictsKey = "commands.overwrite-conflicts";
private Runnable afterHostResourcesReload = () -> { };
+ private FeatureFrameworkObserver observer = FeatureFrameworkObserver.noop();
private final List> contributors = new ArrayList<>();
private final List> bootstrapCapabilities = new ArrayList<>();
private final List themes = new ArrayList<>();
@@ -236,6 +239,9 @@ public Builder overwriteCommandConflictsKey(String value) {
public Builder
afterHostResourcesReload(Runnable value) {
afterHostResourcesReload = Objects.requireNonNull(value, "afterHostResourcesReload"); return this;
}
+ public Builder
observer(FeatureFrameworkObserver value) {
+ observer = Objects.requireNonNull(value, "observer"); return this;
+ }
public Builder
contribute(FeatureResourceContributor value) {
contributors.add(Objects.requireNonNull(value, "contributor")); return this;
}
diff --git a/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java b/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java
index 41b98ed..d814f58 100644
--- a/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java
+++ b/featureframework-velocity/src/main/java/nl/hauntedmc/featureframework/velocity/host/VelocityFeatureHost.java
@@ -7,6 +7,7 @@
import nl.hauntedmc.featureframework.api.RuntimeState;
import nl.hauntedmc.featureframework.api.feature.FeatureCatalog;
import nl.hauntedmc.featureframework.api.feature.FeatureId;
+import nl.hauntedmc.featureframework.api.observation.FeatureFrameworkObserver;
import nl.hauntedmc.featureframework.api.service.CapabilityRegistry;
import nl.hauntedmc.featureframework.config.DefaultFeatureConfiguration;
import nl.hauntedmc.featureframework.config.FeatureConfigHandler;
@@ -91,7 +92,8 @@ private VelocityFeatureHost(Builder builder) {
builder.afterGraphMutation,
localization::reloadLocalization,
builder.afterHostResourcesReload,
- frameworkLogger
+ frameworkLogger,
+ builder.observer
);
}
@@ -200,6 +202,7 @@ public static final class Builder
{
private Function languageResolver = player -> Language.EN;
private Runnable afterGraphMutation = () -> { };
private Runnable afterHostResourcesReload = () -> { };
+ private FeatureFrameworkObserver observer = FeatureFrameworkObserver.noop();
private final List> contributors = new ArrayList<>();
private final List> bootstrapCapabilities = new ArrayList<>();
private final List themes = new ArrayList<>();
@@ -237,6 +240,9 @@ public Builder afterGraphMutation(Runnable value) {
public Builder
afterHostResourcesReload(Runnable value) {
afterHostResourcesReload = Objects.requireNonNull(value, "afterHostResourcesReload"); return this;
}
+ public Builder
observer(FeatureFrameworkObserver value) {
+ observer = Objects.requireNonNull(value, "observer"); return this;
+ }
public Builder
contribute(FeatureResourceContributor value) {
contributors.add(Objects.requireNonNull(value, "contributor")); return this;
}
diff --git a/pom.xml b/pom.xml
index ca4ab5e..62453dd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
nl.hauntedmc.platform
haunted-library-parent
- 1.2.0
+ 1.3.0
@@ -102,8 +102,8 @@
1.3.10
2.12.3
5.11.0
- 1.14.3
- 3.2.0
+ 1.15.0
+ 3.3.0
${haunted.jpa.version}
${haunted.junit.version}