Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d6dfaa4
Add neutral FeatureFramework observation contract
remdui Aug 25, 2026
0cbf728
Add neutral FeatureFramework observation contract
remdui Aug 25, 2026
ceb8e46
Add neutral FeatureFramework observation contract
remdui Aug 25, 2026
708bbd6
Add neutral FeatureFramework observation contract
remdui Aug 25, 2026
bd16a0f
Add neutral FeatureFramework observation contract
remdui Aug 25, 2026
152ce16
Add neutral FeatureFramework observation contract
remdui Aug 25, 2026
fbca380
Add fail-open FeatureFramework observation dispatcher
remdui Aug 25, 2026
bd348e4
Align FeatureFramework with observability foundation releases
remdui Aug 25, 2026
fea707c
Use HauntedPlatform 1.3.0 CI policy
remdui Aug 25, 2026
3d36185
Inject optional observer through host composition
remdui Aug 25, 2026
4c98fdd
Expose observer through Paper host builder
remdui Aug 25, 2026
2029b76
Expose observer through Velocity host builder
remdui Aug 25, 2026
45355ce
Centralize fail-open observation wrapping
remdui Aug 25, 2026
8e1740d
Preserve checked failures in observed work
remdui Aug 25, 2026
36a35a7
Observe actual feature startup lifecycle
remdui Aug 25, 2026
47e61f0
Observe FeatureFramework host lifecycle operations
remdui Aug 25, 2026
ed4c9e1
Document FeatureFramework observation contract
remdui Aug 25, 2026
46e5ffb
Test neutral observation fail-open contract
remdui Aug 25, 2026
e791eee
Avoid unused observation scope warnings
remdui Aug 25, 2026
f01bb96
Close feature observation scope explicitly
remdui Aug 25, 2026
096aeaf
Guard neutral observation API boundary
remdui Aug 25, 2026
e3b53b6
Close host observation scope explicitly
remdui Aug 25, 2026
005bbb1
Verify observed host lifecycle semantics
remdui Aug 25, 2026
4340702
Harden observation no-op fast path
remdui Aug 25, 2026
4a6da5c
Avoid filtered feature-load observation allocation
remdui Aug 25, 2026
b1f051e
Strengthen telemetry architecture boundary
remdui Aug 25, 2026
101139b
Enforce neutral core observation boundary
remdui Aug 25, 2026
6d9460a
Cover observation fast-path isolation
remdui Aug 25, 2026
2a5f7ff
Clarify observer failure isolation contract
remdui Aug 25, 2026
dac8388
Clarify observation failure and fast-path semantics
remdui Aug 25, 2026
528d103
Align observer Javadoc with callback policy
remdui Aug 25, 2026
b515206
Normalize root POM formatting
remdui Aug 25, 2026
8e3a6a6
Fix release profile XML
remdui Aug 25, 2026
66a11f9
Link lifecycle observation documentation
remdui Aug 25, 2026
e7f783b
Document FeatureFramework 1.7 release order
remdui Aug 25, 2026
df4ed64
Clarify observation filtering fast path
remdui Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
103 changes: 103 additions & 0 deletions docs/OBSERVATION.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 4 additions & 3 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
}
}
Original file line number Diff line number Diff line change
@@ -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.
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*/
public record FeatureFrameworkOperationContext(
FeatureFrameworkOperationKind operation,
Optional<FeatureId> 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")));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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"
);
}
}
}
}
Loading