diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml
index 278fb82d..7cf6b6e2 100644
--- a/.github/workflows/ci-lint.yml
+++ b/.github/workflows/ci-lint.yml
@@ -13,7 +13,7 @@ concurrency:
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/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java
index 1994d0b5..57e300bf 100644
--- a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java
+++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/DataProviderAPI.java
@@ -1,5 +1,6 @@
package nl.hauntedmc.dataprovider.api;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver;
import nl.hauntedmc.dataprovider.api.orm.ORMContext;
import nl.hauntedmc.dataprovider.database.DatabaseProvider;
import nl.hauntedmc.dataprovider.database.DatabaseType;
@@ -19,6 +20,18 @@ default DataProviderAPI forPlugin(Object platformPlugin) {
throw new UnsupportedOperationException("This DataProvider API does not support plugin binding.");
}
+ /**
+ * Returns a facade that reports its operations to the supplied vendor-neutral observer.
+ *
+ *
The observer remains scoped to this facade, its child scopes, and handles obtained from it;
+ * it is never installed globally. Implementations predating observation support may return this
+ * facade unchanged.
+ */
+ default DataProviderAPI withObserver(DataProviderObserver observer) {
+ Objects.requireNonNull(observer, "DataProvider observer cannot be null.");
+ return this;
+ }
+
/**
* Creates an ORM context using the platform administrator's configured schema mode.
*
diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObservation.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObservation.java
new file mode 100644
index 00000000..00a8bbe3
--- /dev/null
+++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObservation.java
@@ -0,0 +1,40 @@
+package nl.hauntedmc.dataprovider.api.observation;
+
+/**
+ * One in-flight DataProvider operation observed through {@link DataProviderObserver}.
+ *
+ * Exactly one terminal callback is issued by the DataProvider runtime for an observation it
+ * successfully starts. Implementations must be thread-safe because asynchronous operations may
+ * complete on a backend worker thread rather than the thread that started the observation.
+ */
+public interface DataProviderObservation {
+
+ /** Called when the observed operation completes successfully. */
+ void succeeded();
+
+ /** Called when the observed operation completes exceptionally or throws. */
+ void failed(Throwable failure);
+
+ /** Returns a reusable observation that ignores all terminal callbacks. */
+ static DataProviderObservation noop() {
+ return NoopDataProviderObservation.INSTANCE;
+ }
+}
+
+final class NoopDataProviderObservation implements DataProviderObservation {
+
+ static final NoopDataProviderObservation INSTANCE = new NoopDataProviderObservation();
+
+ private NoopDataProviderObservation() {
+ }
+
+ @Override
+ public void succeeded() {
+ // Intentionally empty.
+ }
+
+ @Override
+ public void failed(Throwable failure) {
+ // Intentionally empty.
+ }
+}
diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java
new file mode 100644
index 00000000..2b6627d0
--- /dev/null
+++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderObserver.java
@@ -0,0 +1,36 @@
+package nl.hauntedmc.dataprovider.api.observation;
+
+/**
+ * Vendor-neutral hook for observing plugin-scoped DataProvider operations.
+ *
+ * The runtime invokes observers synchronously when an operation starts. The returned observation
+ * receives its terminal callback when that operation completes; asynchronous operations may finish
+ * on a backend worker thread. Implementations should therefore be thread-safe and non-blocking.
+ *
+ * Observer failures are isolated by the DataProvider runtime and never change the outcome of the
+ * underlying data operation.
+ */
+@FunctionalInterface
+public interface DataProviderObserver {
+
+ /** Starts one operation observation. Implementations should return a non-null handle. */
+ DataProviderObservation start(DataProviderOperationContext context);
+
+ /** Returns the reusable no-op observer used by the uninstrumented fast path. */
+ static DataProviderObserver noop() {
+ return NoopDataProviderObserver.INSTANCE;
+ }
+}
+
+final class NoopDataProviderObserver implements DataProviderObserver {
+
+ static final NoopDataProviderObserver INSTANCE = new NoopDataProviderObserver();
+
+ private NoopDataProviderObserver() {
+ }
+
+ @Override
+ public DataProviderObservation start(DataProviderOperationContext context) {
+ return DataProviderObservation.noop();
+ }
+}
diff --git a/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderOperationContext.java b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderOperationContext.java
new file mode 100644
index 00000000..b4190236
--- /dev/null
+++ b/dataprovider-api/src/main/java/nl/hauntedmc/dataprovider/api/observation/DataProviderOperationContext.java
@@ -0,0 +1,40 @@
+package nl.hauntedmc.dataprovider.api.observation;
+
+import nl.hauntedmc.dataprovider.api.OwnerScope;
+import nl.hauntedmc.dataprovider.database.DatabaseType;
+
+import java.util.Objects;
+
+/**
+ * Stable, payload-free metadata describing one observed DataProvider operation.
+ *
+ * The operation name comes from DataProvider's bounded public operation vocabulary, for example
+ * {@code database.register}, {@code relational.queryForSingle}, or {@code keyvalue.getKey}.
+ * Connection identifiers, SQL text, keys, destinations, payloads, credentials, and player data are
+ * deliberately excluded from this contract.
+ *
+ * @param pluginId platform-derived plugin identity that owns the API facade
+ * @param ownerScope public lifecycle owner scope; internal unique registration scopes are never exposed
+ * @param databaseType backend used by the operation
+ * @param operation stable DataProvider operation name
+ */
+public record DataProviderOperationContext(
+ String pluginId,
+ OwnerScope ownerScope,
+ DatabaseType databaseType,
+ String operation
+) {
+
+ public DataProviderOperationContext {
+ Objects.requireNonNull(pluginId, "Plugin id cannot be null.");
+ Objects.requireNonNull(ownerScope, "Owner scope cannot be null.");
+ Objects.requireNonNull(databaseType, "Database type cannot be null.");
+ Objects.requireNonNull(operation, "Operation cannot be null.");
+ if (pluginId.isBlank()) {
+ throw new IllegalArgumentException("Plugin id cannot be blank.");
+ }
+ if (operation.isBlank()) {
+ throw new IllegalArgumentException("Operation cannot be blank.");
+ }
+ }
+}
diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java
new file mode 100644
index 00000000..da40a068
--- /dev/null
+++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservations.java
@@ -0,0 +1,118 @@
+package nl.hauntedmc.dataprovider.core.api;
+
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObservation;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext;
+
+import java.util.Objects;
+import java.util.concurrent.CompletionStage;
+import java.util.function.Supplier;
+
+/** Isolates optional observation callbacks from the data path. */
+final class DataProviderObservations {
+
+ private DataProviderObservations() {
+ }
+
+ static boolean isEnabled(DataProviderObserver observer) {
+ return observer != null && observer != DataProviderObserver.noop();
+ }
+
+ static T observe(
+ DataProviderObserver observer,
+ DataProviderOperationContext context,
+ Supplier operation
+ ) {
+ Objects.requireNonNull(operation, "Operation cannot be null.");
+ if (!isEnabled(observer)) {
+ return operation.get();
+ }
+ DataProviderObservation observation = start(observer, context);
+ try {
+ T result = operation.get();
+ succeeded(observation);
+ return result;
+ } catch (RuntimeException | Error failure) {
+ failed(observation, failure);
+ throw failure;
+ }
+ }
+
+ static void observe(
+ DataProviderObserver observer,
+ DataProviderOperationContext context,
+ Runnable operation
+ ) {
+ observe(observer, context, () -> {
+ operation.run();
+ return null;
+ });
+ }
+
+ static Object observeInvocation(
+ DataProviderObserver observer,
+ DataProviderOperationContext context,
+ ThrowingOperation operation
+ ) throws Throwable {
+ if (!isEnabled(observer)) {
+ return operation.execute();
+ }
+ DataProviderObservation observation = start(observer, context);
+ final Object result;
+ try {
+ result = operation.execute();
+ } catch (Throwable failure) {
+ failed(observation, failure);
+ throw failure;
+ }
+ if (result instanceof CompletionStage> completionStage) {
+ try {
+ completionStage.whenComplete((ignored, failure) -> {
+ if (failure == null) {
+ succeeded(observation);
+ } else {
+ failed(observation, failure);
+ }
+ });
+ } catch (RuntimeException ignored) {
+ succeeded(observation);
+ }
+ } else {
+ succeeded(observation);
+ }
+ return result;
+ }
+
+ private static DataProviderObservation start(
+ DataProviderObserver observer,
+ DataProviderOperationContext context
+ ) {
+ try {
+ DataProviderObservation observation = observer.start(context);
+ return observation == null ? DataProviderObservation.noop() : observation;
+ } catch (RuntimeException ignored) {
+ return DataProviderObservation.noop();
+ }
+ }
+
+ private static void succeeded(DataProviderObservation observation) {
+ try {
+ observation.succeeded();
+ } catch (RuntimeException ignored) {
+ // Observability must never change a successful data operation into a failure.
+ }
+ }
+
+ private static void failed(DataProviderObservation observation, Throwable failure) {
+ try {
+ observation.failed(failure);
+ } catch (RuntimeException ignored) {
+ // Preserve the original data-operation failure.
+ }
+ }
+
+ @FunctionalInterface
+ interface ThrowingOperation {
+ Object execute() throws Throwable;
+ }
+}
diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java
index 03575850..a84e9c87 100644
--- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java
+++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderApi.java
@@ -3,6 +3,8 @@
import nl.hauntedmc.dataprovider.api.DataProviderAPI;
import nl.hauntedmc.dataprovider.api.DataProviderScope;
import nl.hauntedmc.dataprovider.api.OwnerScope;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext;
import nl.hauntedmc.dataprovider.api.orm.ORMContext;
import nl.hauntedmc.dataprovider.core.DataProviderHandler;
import nl.hauntedmc.dataprovider.core.identity.PluginIdentity;
@@ -18,20 +20,34 @@ public final class DefaultDataProviderApi implements DataProviderAPI {
private final DataProviderHandler handler;
private final PluginIdentity identity;
+ private final DataProviderObserver observer;
public DefaultDataProviderApi(DataProviderHandler handler) {
- this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null");
- this.identity = null;
+ this(handler, null, DataProviderObserver.noop());
}
- private DefaultDataProviderApi(DataProviderHandler handler, PluginIdentity identity) {
+ private DefaultDataProviderApi(
+ DataProviderHandler handler,
+ PluginIdentity identity,
+ DataProviderObserver observer
+ ) {
this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null");
- this.identity = Objects.requireNonNull(identity, "Plugin identity cannot be null");
+ this.identity = identity;
+ this.observer = Objects.requireNonNull(observer, "DataProvider observer cannot be null.");
}
@Override
public DataProviderAPI forPlugin(Object platformPlugin) {
- return new DefaultDataProviderApi(handler, handler.issuePluginIdentity(platformPlugin));
+ return new DefaultDataProviderApi(handler, handler.issuePluginIdentity(platformPlugin), observer);
+ }
+
+ @Override
+ public DataProviderAPI withObserver(DataProviderObserver dataProviderObserver) {
+ return new DefaultDataProviderApi(
+ handler,
+ identity,
+ Objects.requireNonNull(dataProviderObserver, "DataProvider observer cannot be null.")
+ );
}
@Override
@@ -48,13 +64,27 @@ public ORMContext createOrmContext(
}
PluginIdentity boundIdentity = requireIdentity();
Class>[] validatedEntities = validateEntityClasses(boundIdentity, entityClasses);
- return new nl.hauntedmc.dataprovider.core.orm.ORMContext(
- handler.getPluginId(boundIdentity),
+ String pluginId = handler.getPluginId(boundIdentity);
+ ORMContext context = new nl.hauntedmc.dataprovider.core.orm.ORMContext(
+ pluginId,
dataSource,
logger,
handler.getConfiguredOrmSchemaMode(boundIdentity),
validatedEntities
);
+ if (!DataProviderObservations.isEnabled(observer)) {
+ return context;
+ }
+ return new ObservedOrmContext(
+ context,
+ observer,
+ operationContext(
+ pluginId,
+ IdentityBoundDatabaseProvider.boundOwnerScope(dataSource),
+ IdentityBoundDatabaseProvider.boundDatabaseType(dataSource),
+ "orm.runInTransaction"
+ )
+ );
}
/** Package-visible for API-path regression tests. */
@@ -92,20 +122,60 @@ private static boolean isOwnedOrSharedClass(ClassLoader pluginLoader, ClassLoade
@Override
public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) {
PluginIdentity boundIdentity = requireIdentity();
- return wrapProvider(handler, boundIdentity,
- handler.registerDatabaseOrThrow(boundIdentity, databaseType, connectionIdentifier));
+ String pluginId = boundIdentity.pluginId();
+ OwnerScope ownerScope = OwnerScope.of(pluginId);
+ if (!DataProviderObservations.isEnabled(observer)) {
+ return wrapProvider(
+ handler,
+ boundIdentity,
+ handler.registerDatabaseOrThrow(boundIdentity, databaseType, connectionIdentifier),
+ observer,
+ pluginId,
+ ownerScope,
+ databaseType
+ );
+ }
+ return DataProviderObservations.observe(
+ observer,
+ operationContext(pluginId, ownerScope, databaseType, "database.register"),
+ () -> wrapProvider(
+ handler,
+ boundIdentity,
+ handler.registerDatabaseOrThrow(boundIdentity, databaseType, connectionIdentifier),
+ observer,
+ pluginId,
+ ownerScope,
+ databaseType
+ )
+ );
}
@Override
public DataProviderScope scope(OwnerScope ownerScope) {
PluginIdentity boundIdentity = requireIdentity();
handler.requireIdentity(boundIdentity);
- return new DefaultDataProviderScope(handler, ownerScope, boundIdentity);
+ return new DefaultDataProviderScope(
+ handler,
+ ownerScope,
+ boundIdentity,
+ observer,
+ boundIdentity.pluginId()
+ );
}
@Override
public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) {
- handler.unregisterDatabase(requireIdentity(), databaseType, connectionIdentifier);
+ PluginIdentity boundIdentity = requireIdentity();
+ if (!DataProviderObservations.isEnabled(observer)) {
+ handler.unregisterDatabase(boundIdentity, databaseType, connectionIdentifier);
+ return;
+ }
+ String pluginId = boundIdentity.pluginId();
+ DataProviderObservations.observe(
+ observer,
+ operationContext(pluginId, OwnerScope.of(pluginId), databaseType, "database.unregister"),
+ () -> handler.unregisterDatabase(boundIdentity, databaseType, connectionIdentifier)
+ );
}
@Override
@@ -121,16 +191,56 @@ public void unregisterAllDatabasesForPlugin() {
@Override
public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) {
PluginIdentity boundIdentity = requireIdentity();
- return wrapProvider(handler, boundIdentity,
- handler.requireRegisteredDatabase(boundIdentity, databaseType, connectionIdentifier));
+ String pluginId = boundIdentity.pluginId();
+ OwnerScope ownerScope = OwnerScope.of(pluginId);
+ return wrapProvider(
+ handler,
+ boundIdentity,
+ handler.requireRegisteredDatabase(boundIdentity, databaseType, connectionIdentifier),
+ observer,
+ pluginId,
+ ownerScope,
+ databaseType
+ );
}
static DatabaseProvider wrapProvider(
- DataProviderHandler handler, PluginIdentity identity, DatabaseProvider provider
+ DataProviderHandler handler,
+ PluginIdentity identity,
+ DatabaseProvider provider
) {
return IdentityBoundDatabaseProvider.wrap(handler, identity, provider);
}
+ static DatabaseProvider wrapProvider(
+ DataProviderHandler handler,
+ PluginIdentity identity,
+ DatabaseProvider provider,
+ DataProviderObserver observer,
+ String pluginId,
+ OwnerScope ownerScope,
+ DatabaseType databaseType
+ ) {
+ return IdentityBoundDatabaseProvider.wrap(
+ handler,
+ identity,
+ provider,
+ observer,
+ pluginId,
+ ownerScope,
+ databaseType
+ );
+ }
+
+ private static DataProviderOperationContext operationContext(
+ String pluginId,
+ OwnerScope ownerScope,
+ DatabaseType databaseType,
+ String operation
+ ) {
+ return new DataProviderOperationContext(pluginId, ownerScope, databaseType, operation);
+ }
+
private PluginIdentity requireIdentity() {
if (identity == null) {
throw new IllegalStateException("Bind DataProviderAPI with forPlugin(plugin) before use.");
diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java
index b010fb2e..6d19f68e 100644
--- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java
+++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/DefaultDataProviderScope.java
@@ -2,6 +2,8 @@
import nl.hauntedmc.dataprovider.api.DataProviderScope;
import nl.hauntedmc.dataprovider.api.OwnerScope;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext;
import nl.hauntedmc.dataprovider.core.DataProviderHandler;
import nl.hauntedmc.dataprovider.core.identity.PluginIdentity;
import nl.hauntedmc.dataprovider.database.DatabaseProvider;
@@ -24,14 +26,28 @@ public final class DefaultDataProviderScope implements DataProviderScope {
private final OwnerScope ownerScope;
private final OwnerScope registrationScope;
private final PluginIdentity identity;
+ private final DataProviderObserver observer;
+ private final String pluginId;
private final Object lifecycleMonitor = new Object();
private volatile LifecycleState lifecycleState = LifecycleState.OPEN;
DefaultDataProviderScope(DataProviderHandler handler, OwnerScope ownerScope, PluginIdentity identity) {
+ this(handler, ownerScope, identity, DataProviderObserver.noop(), identity.pluginId());
+ }
+
+ DefaultDataProviderScope(
+ DataProviderHandler handler,
+ OwnerScope ownerScope,
+ PluginIdentity identity,
+ DataProviderObserver observer,
+ String pluginId
+ ) {
this.handler = Objects.requireNonNull(handler, "DataProviderHandler cannot be null.");
this.ownerScope = Objects.requireNonNull(ownerScope, "Owner scope cannot be null.");
this.registrationScope = uniqueRegistrationScope(ownerScope);
this.identity = Objects.requireNonNull(identity, "Plugin identity cannot be null.");
+ this.observer = Objects.requireNonNull(observer, "DataProvider observer cannot be null.");
+ this.pluginId = Objects.requireNonNull(pluginId, "Plugin id cannot be null.");
}
@Override
@@ -50,13 +66,13 @@ public LifecycleState lifecycleState() {
public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, String connectionIdentifier) {
synchronized (lifecycleMonitor) {
requireStructuredOpen("scope.registerDatabase");
- return DefaultDataProviderApi.wrapProvider(handler, identity,
- handler.registerDatabaseForScopeOrThrow(
- identity,
- registrationScope,
- databaseType,
- connectionIdentifier
- )
+ if (!DataProviderObservations.isEnabled(observer)) {
+ return registerAndWrap(databaseType, connectionIdentifier);
+ }
+ return DataProviderObservations.observe(
+ observer,
+ operationContext(databaseType, "database.register"),
+ () -> registerAndWrap(databaseType, connectionIdentifier)
);
}
}
@@ -65,7 +81,20 @@ public DatabaseProvider registerDatabaseOrThrow(DatabaseType databaseType, Strin
public void unregisterDatabase(DatabaseType databaseType, String connectionIdentifier) {
synchronized (lifecycleMonitor) {
requireCleanupOpen("scope.unregisterDatabase");
- handler.unregisterDatabaseForScope(identity, registrationScope, databaseType, connectionIdentifier);
+ if (!DataProviderObservations.isEnabled(observer)) {
+ handler.unregisterDatabaseForScope(identity, registrationScope, databaseType, connectionIdentifier);
+ return;
+ }
+ DataProviderObservations.observe(
+ observer,
+ operationContext(databaseType, "database.unregister"),
+ () -> handler.unregisterDatabaseForScope(
+ identity,
+ registrationScope,
+ databaseType,
+ connectionIdentifier
+ )
+ );
}
}
@@ -81,13 +110,19 @@ public void unregisterAllDatabases() {
public DatabaseProvider requireRegisteredDatabase(DatabaseType databaseType, String connectionIdentifier) {
synchronized (lifecycleMonitor) {
requireStructuredOpen("scope.requireRegisteredDatabase");
- return DefaultDataProviderApi.wrapProvider(handler, identity,
+ return DefaultDataProviderApi.wrapProvider(
+ handler,
+ identity,
handler.requireRegisteredDatabaseForScope(
identity,
registrationScope,
databaseType,
connectionIdentifier
- )
+ ),
+ observer,
+ pluginId,
+ ownerScope,
+ databaseType
);
}
}
@@ -110,6 +145,27 @@ public void close() {
}
}
+ private DatabaseProvider registerAndWrap(DatabaseType databaseType, String connectionIdentifier) {
+ return DefaultDataProviderApi.wrapProvider(
+ handler,
+ identity,
+ handler.registerDatabaseForScopeOrThrow(
+ identity,
+ registrationScope,
+ databaseType,
+ connectionIdentifier
+ ),
+ observer,
+ pluginId,
+ ownerScope,
+ databaseType
+ );
+ }
+
+ private DataProviderOperationContext operationContext(DatabaseType databaseType, String operation) {
+ return new DataProviderOperationContext(pluginId, ownerScope, databaseType, operation);
+ }
+
private void requireStructuredOpen(String operation) {
requireOwner();
requireLocallyOpen(operation);
diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java
index 49252817..2a6832df 100644
--- a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java
+++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/IdentityBoundDatabaseProvider.java
@@ -1,11 +1,17 @@
package nl.hauntedmc.dataprovider.core.api;
+import nl.hauntedmc.dataprovider.api.OwnerScope;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext;
import nl.hauntedmc.dataprovider.core.DataProviderHandler;
import nl.hauntedmc.dataprovider.core.concurrent.ScopedDataSource;
import nl.hauntedmc.dataprovider.core.identity.PluginIdentity;
import nl.hauntedmc.dataprovider.database.DataAccess;
import nl.hauntedmc.dataprovider.database.DatabaseProvider;
+import nl.hauntedmc.dataprovider.database.DatabaseType;
+import nl.hauntedmc.dataprovider.database.document.DocumentDataAccess;
import nl.hauntedmc.dataprovider.database.document.DocumentDatabaseProvider;
+import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDataAccess;
import nl.hauntedmc.dataprovider.database.keyvalue.KeyValueDatabaseProvider;
import nl.hauntedmc.dataprovider.database.messaging.MessagingDataAccess;
import nl.hauntedmc.dataprovider.database.messaging.MessagingDatabaseProvider;
@@ -13,6 +19,7 @@
import nl.hauntedmc.dataprovider.database.messaging.durable.DurableDelivery;
import nl.hauntedmc.dataprovider.database.messaging.durable.DurableMessagingDataAccess;
import nl.hauntedmc.dataprovider.database.messaging.durable.DurableSubscription;
+import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess;
import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider;
import nl.hauntedmc.dataprovider.database.relational.schema.SchemaManager;
@@ -42,13 +49,68 @@ static DatabaseProvider wrap(DataProviderHandler handler, PluginIdentity identit
if (provider == null) {
return null;
}
- return (DatabaseProvider) proxy(provider, handler, identity, providerInterfaces(provider));
+ DatabaseType databaseType = inferDatabaseType(provider);
+ ObservationTarget target = new ObservationTarget(
+ DataProviderObserver.noop(),
+ identity.pluginId(),
+ OwnerScope.of(identity.pluginId()),
+ databaseType
+ );
+ return (DatabaseProvider) proxy(provider, handler, identity, providerInterfaces(provider), target);
+ }
+
+ static DatabaseProvider wrap(
+ DataProviderHandler handler,
+ PluginIdentity identity,
+ DatabaseProvider provider,
+ DataProviderObserver observer,
+ String pluginId,
+ OwnerScope ownerScope,
+ DatabaseType databaseType
+ ) {
+ if (provider == null) {
+ return null;
+ }
+ ObservationTarget target = new ObservationTarget(observer, pluginId, ownerScope, databaseType);
+ return (DatabaseProvider) proxy(provider, handler, identity, providerInterfaces(provider), target);
}
static boolean isBoundDataSource(DataSource dataSource) {
return dataSource instanceof GuardedDataSource;
}
+ static DatabaseType boundDatabaseType(DataSource dataSource) {
+ return boundObservationTarget(dataSource).databaseType();
+ }
+
+ static OwnerScope boundOwnerScope(DataSource dataSource) {
+ return boundObservationTarget(dataSource).ownerScope();
+ }
+
+ private static ObservationTarget boundObservationTarget(DataSource dataSource) {
+ if (!(dataSource instanceof GuardedDataSource guardedDataSource)) {
+ throw new IllegalArgumentException("DataSource is not bound to a DataProvider registration.");
+ }
+ return guardedDataSource.target;
+ }
+
+ private static DatabaseType inferDatabaseType(DatabaseProvider provider) {
+ if (provider instanceof RelationalDatabaseProvider) {
+ return DatabaseType.MYSQL;
+ }
+ if (provider instanceof DocumentDatabaseProvider) {
+ return DatabaseType.MONGODB;
+ }
+ if (provider instanceof KeyValueDatabaseProvider) {
+ return DatabaseType.REDIS;
+ }
+ if (provider instanceof MessagingDatabaseProvider) {
+ return DatabaseType.REDIS_MESSAGING;
+ }
+ throw new IllegalArgumentException("Unsupported DataProvider database-provider contract: "
+ + provider.getClass().getName());
+ }
+
private static Class>[] providerInterfaces(DatabaseProvider provider) {
if (provider instanceof RelationalDatabaseProvider) {
return new Class>[] {RelationalDatabaseProvider.class};
@@ -65,12 +127,18 @@ private static Class>[] providerInterfaces(DatabaseProvider provider) {
return new Class>[] {DatabaseProvider.class};
}
- private static Object proxy(Object delegate, DataProviderHandler handler, PluginIdentity identity, Class>[] interfaces) {
+ private static Object proxy(
+ Object delegate,
+ DataProviderHandler handler,
+ PluginIdentity identity,
+ Class>[] interfaces,
+ ObservationTarget target
+ ) {
Objects.requireNonNull(delegate, "Delegate cannot be null.");
Objects.requireNonNull(handler, "Handler cannot be null.");
return Proxy.newProxyInstance(
IdentityBoundDatabaseProvider.class.getClassLoader(), interfaces,
- new GuardedInvocation(delegate, handler, identity)
+ new GuardedInvocation(delegate, handler, identity, target)
);
}
@@ -78,11 +146,18 @@ private static final class GuardedInvocation implements InvocationHandler {
private final Object delegate;
private final DataProviderHandler handler;
private final PluginIdentity identity;
+ private final ObservationTarget target;
- private GuardedInvocation(Object delegate, DataProviderHandler handler, PluginIdentity identity) {
+ private GuardedInvocation(
+ Object delegate,
+ DataProviderHandler handler,
+ PluginIdentity identity,
+ ObservationTarget target
+ ) {
this.delegate = delegate;
this.handler = handler;
this.identity = identity;
+ this.target = target;
}
@Override
@@ -101,10 +176,32 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl
if (method.getName().equals("isWrapperFor") && args != null && args.length == 1) {
return ((Class>) args[0]).isInstance(proxy);
}
+
+ Object[] invocationArguments = guardedDurableHandlerArguments(
+ method,
+ args,
+ handler,
+ identity,
+ target
+ );
+ if (!DataProviderObservations.isEnabled(target.observer())) {
+ return invokeAndBind(method, invocationArguments);
+ }
+ String operation = observationOperation(method);
+ if (operation == null) {
+ return invokeAndBind(method, invocationArguments);
+ }
+ return DataProviderObservations.observeInvocation(
+ target.observer(),
+ target.context(operation),
+ () -> invokeAndBind(method, invocationArguments)
+ );
+ }
+
+ private Object invokeAndBind(Method method, Object[] invocationArguments) throws Throwable {
try {
- Object[] invocationArguments = guardedDurableHandlerArguments(method, args, handler, identity);
Object result = method.invoke(delegate, invocationArguments);
- return bindResult(method.getReturnType(), result, handler, identity);
+ return bindResult(method.getReturnType(), result, handler, identity, target);
} catch (InvocationTargetException exception) {
throw exception.getCause();
}
@@ -115,7 +212,8 @@ private static Object[] guardedDurableHandlerArguments(
Method method,
Object[] arguments,
DataProviderHandler handler,
- PluginIdentity identity
+ PluginIdentity identity,
+ ObservationTarget target
) {
if (!method.getName().equals("consume") || arguments == null || arguments.length != 6
|| !(arguments[5] instanceof Consumer originalHandler)) {
@@ -123,38 +221,90 @@ private static Object[] guardedDurableHandlerArguments(
}
Object[] guarded = arguments.clone();
guarded[5] = (Consumer>) delivery -> originalHandler.accept(
- (DurableDelivery>) bindResult(DurableDelivery.class, delivery, handler, identity));
+ (DurableDelivery>) bindResult(
+ DurableDelivery.class,
+ delivery,
+ handler,
+ identity,
+ target
+ )
+ );
return guarded;
}
}
- private static Object bindResult(Class> returnType, Object result, DataProviderHandler handler, PluginIdentity identity) {
+ private static Object bindResult(
+ Class> returnType,
+ Object result,
+ DataProviderHandler handler,
+ PluginIdentity identity,
+ ObservationTarget target
+ ) {
if (result == null) {
return null;
}
if (result instanceof DataSource dataSource) {
- return new GuardedDataSource(dataSource, handler, identity);
+ return new GuardedDataSource(dataSource, handler, identity, target);
}
if (result instanceof Subscription subscription) {
- return proxy(subscription, handler, identity, new Class>[] {Subscription.class});
+ return proxy(subscription, handler, identity, new Class>[] {Subscription.class}, target);
}
if (result instanceof DurableSubscription subscription) {
- return proxy(subscription, handler, identity, new Class>[] {DurableSubscription.class});
+ return proxy(subscription, handler, identity, new Class>[] {DurableSubscription.class}, target);
}
if (result instanceof DurableMessagingDataAccess access) {
- return proxy(access, handler, identity, new Class>[] {DurableMessagingDataAccess.class});
+ return proxy(access, handler, identity, new Class>[] {DurableMessagingDataAccess.class}, target);
}
if (result instanceof DurableDelivery> delivery) {
- return proxy(delivery, handler, identity, new Class>[] {DurableDelivery.class});
+ return proxy(delivery, handler, identity, new Class>[] {DurableDelivery.class}, target);
}
if (returnType.isInterface() && (DataAccess.class.isAssignableFrom(returnType)
|| SchemaManager.class.isAssignableFrom(returnType)
|| returnType.getPackageName().startsWith("java.sql"))) {
- return proxy(result, handler, identity, new Class>[] {returnType});
+ return proxy(result, handler, identity, new Class>[] {returnType}, target);
}
return result;
}
+ private static String observationOperation(Method method) {
+ Class> declaringClass = method.getDeclaringClass();
+ String methodName = method.getName();
+ if (RelationalDataAccess.class.isAssignableFrom(declaringClass)) {
+ return "relational." + methodName;
+ }
+ if (DocumentDataAccess.class.isAssignableFrom(declaringClass)) {
+ return "document." + methodName;
+ }
+ if (KeyValueDataAccess.class.isAssignableFrom(declaringClass)) {
+ return "keyvalue." + methodName;
+ }
+ if (SchemaManager.class.isAssignableFrom(declaringClass)) {
+ return "schema." + methodName;
+ }
+ if (DurableMessagingDataAccess.class.isAssignableFrom(declaringClass)) {
+ return switch (methodName) {
+ case "publish", "consume", "shutdown" -> "messaging.durable." + methodName;
+ default -> null;
+ };
+ }
+ if (MessagingDataAccess.class.isAssignableFrom(declaringClass)) {
+ return switch (methodName) {
+ case "publish", "subscribe", "shutdown" -> "messaging." + methodName;
+ default -> null;
+ };
+ }
+ if (Subscription.class.isAssignableFrom(declaringClass) && methodName.equals("unsubscribe")) {
+ return "messaging.subscription.unsubscribe";
+ }
+ if (DurableSubscription.class.isAssignableFrom(declaringClass) && methodName.equals("closeAsync")) {
+ return "messaging.durable.subscription.closeAsync";
+ }
+ if (DurableDelivery.class.isAssignableFrom(declaringClass) && methodName.equals("acknowledge")) {
+ return "messaging.durable.acknowledge";
+ }
+ return null;
+ }
+
private static boolean isCleanupMethod(Method method) {
String name = method.getName();
Class> declaringClass = method.getDeclaringClass();
@@ -180,11 +330,18 @@ private static final class GuardedDataSource implements ScopedDataSource {
private final DataSource delegate;
private final DataProviderHandler handler;
private final PluginIdentity identity;
+ private final ObservationTarget target;
- private GuardedDataSource(DataSource delegate, DataProviderHandler handler, PluginIdentity identity) {
+ private GuardedDataSource(
+ DataSource delegate,
+ DataProviderHandler handler,
+ PluginIdentity identity,
+ ObservationTarget target
+ ) {
this.delegate = delegate;
this.handler = handler;
this.identity = identity;
+ this.target = target;
}
private void check() {
@@ -193,11 +350,23 @@ private void check() {
@Override public Connection getConnection() throws SQLException {
check();
- return (Connection) bindResult(Connection.class, delegate.getConnection(), handler, identity);
+ return (Connection) bindResult(
+ Connection.class,
+ delegate.getConnection(),
+ handler,
+ identity,
+ target
+ );
}
@Override public Connection getConnection(String user, String password) throws SQLException {
check();
- return (Connection) bindResult(Connection.class, delegate.getConnection(user, password), handler, identity);
+ return (Connection) bindResult(
+ Connection.class,
+ delegate.getConnection(user, password),
+ handler,
+ identity,
+ target
+ );
}
@Override public PrintWriter getLogWriter() throws SQLException { check(); return delegate.getLogWriter(); }
@Override public void setLogWriter(PrintWriter writer) throws SQLException { check(); delegate.setLogWriter(writer); }
@@ -222,4 +391,22 @@ private static void check(DataProviderHandler handler, PluginIdentity identity,
handler.requireIdentity(boundIdentity);
}
}
+
+ private record ObservationTarget(
+ DataProviderObserver observer,
+ String pluginId,
+ OwnerScope ownerScope,
+ DatabaseType databaseType
+ ) {
+ private ObservationTarget {
+ Objects.requireNonNull(observer, "DataProvider observer cannot be null.");
+ Objects.requireNonNull(pluginId, "Plugin id cannot be null.");
+ Objects.requireNonNull(ownerScope, "Owner scope cannot be null.");
+ Objects.requireNonNull(databaseType, "Database type cannot be null.");
+ }
+
+ private DataProviderOperationContext context(String operation) {
+ return new DataProviderOperationContext(pluginId, ownerScope, databaseType, operation);
+ }
+ }
}
diff --git a/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/ObservedOrmContext.java b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/ObservedOrmContext.java
new file mode 100644
index 00000000..4481a6dc
--- /dev/null
+++ b/dataprovider-core/src/main/java/nl/hauntedmc/dataprovider/core/api/ObservedOrmContext.java
@@ -0,0 +1,40 @@
+package nl.hauntedmc.dataprovider.core.api;
+
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext;
+import nl.hauntedmc.dataprovider.api.orm.ORMContext;
+
+import java.util.Objects;
+
+/** Keeps ORM transaction observation outside the public ORM contract. */
+final class ObservedOrmContext implements ORMContext {
+
+ private final ORMContext delegate;
+ private final DataProviderObserver observer;
+ private final DataProviderOperationContext operationContext;
+
+ ObservedOrmContext(
+ ORMContext delegate,
+ DataProviderObserver observer,
+ DataProviderOperationContext operationContext
+ ) {
+ this.delegate = Objects.requireNonNull(delegate, "ORM context cannot be null.");
+ this.observer = Objects.requireNonNull(observer, "DataProvider observer cannot be null.");
+ this.operationContext = Objects.requireNonNull(operationContext, "Operation context cannot be null.");
+ }
+
+ @Override
+ public T runInTransaction(TransactionCallback callback) {
+ Objects.requireNonNull(callback, "Transaction callback cannot be null.");
+ return DataProviderObservations.observe(
+ observer,
+ operationContext,
+ () -> delegate.runInTransaction(callback)
+ );
+ }
+
+ @Override
+ public void shutdown() {
+ delegate.shutdown();
+ }
+}
diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/BoundDataSourceObservationMetadataTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/BoundDataSourceObservationMetadataTest.java
new file mode 100644
index 00000000..d5234e97
--- /dev/null
+++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/BoundDataSourceObservationMetadataTest.java
@@ -0,0 +1,47 @@
+package nl.hauntedmc.dataprovider.core.api;
+
+import nl.hauntedmc.dataprovider.api.OwnerScope;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver;
+import nl.hauntedmc.dataprovider.core.DataProviderHandler;
+import nl.hauntedmc.dataprovider.core.identity.PluginIdentity;
+import nl.hauntedmc.dataprovider.core.identity.PluginIdentityRegistry;
+import nl.hauntedmc.dataprovider.database.DatabaseType;
+import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider;
+import org.junit.jupiter.api.Test;
+
+import javax.sql.DataSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class BoundDataSourceObservationMetadataTest {
+
+ @Test
+ void boundDataSourceRetainsItsPublicOwnerScopeForOrmObservation() {
+ DataProviderHandler handler = mock(DataProviderHandler.class);
+ PluginIdentity identity = new PluginIdentityRegistry().register(
+ "dataregistry",
+ getClass().getClassLoader()
+ );
+ RelationalDatabaseProvider provider = mock(RelationalDatabaseProvider.class);
+ DataSource dataSource = mock(DataSource.class);
+ OwnerScope ownerScope = OwnerScope.of("profiles");
+ when(provider.getDataSource()).thenReturn(dataSource);
+
+ RelationalDatabaseProvider bound = (RelationalDatabaseProvider) IdentityBoundDatabaseProvider.wrap(
+ handler,
+ identity,
+ provider,
+ DataProviderObserver.noop(),
+ identity.pluginId(),
+ ownerScope,
+ DatabaseType.MYSQL
+ );
+
+ DataSource boundDataSource = bound.getDataSource();
+
+ assertEquals(ownerScope, IdentityBoundDatabaseProvider.boundOwnerScope(boundDataSource));
+ assertEquals(DatabaseType.MYSQL, IdentityBoundDatabaseProvider.boundDatabaseType(boundDataSource));
+ }
+}
diff --git a/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java
new file mode 100644
index 00000000..eed76765
--- /dev/null
+++ b/dataprovider-core/src/test/java/nl/hauntedmc/dataprovider/core/api/DataProviderObservationTest.java
@@ -0,0 +1,305 @@
+package nl.hauntedmc.dataprovider.core.api;
+
+import nl.hauntedmc.dataprovider.api.DataProviderAPI;
+import nl.hauntedmc.dataprovider.api.DataProviderScope;
+import nl.hauntedmc.dataprovider.api.OwnerScope;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObservation;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderObserver;
+import nl.hauntedmc.dataprovider.api.observation.DataProviderOperationContext;
+import nl.hauntedmc.dataprovider.api.orm.ORMContext;
+import nl.hauntedmc.dataprovider.core.DataProviderHandler;
+import nl.hauntedmc.dataprovider.core.identity.PluginIdentity;
+import nl.hauntedmc.dataprovider.core.identity.PluginIdentityRegistry;
+import nl.hauntedmc.dataprovider.database.DatabaseProvider;
+import nl.hauntedmc.dataprovider.database.DatabaseType;
+import nl.hauntedmc.dataprovider.database.relational.RelationalDataAccess;
+import nl.hauntedmc.dataprovider.database.relational.RelationalDatabaseProvider;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.function.BiConsumer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class DataProviderObservationTest {
+
+ @Test
+ void facadeRegistrationReportsOnlyStablePluginScopeAndBackendMetadata() {
+ DataProviderHandler handler = mock(DataProviderHandler.class);
+ PluginIdentity identity = identity("serverfeatures");
+ DatabaseProvider provider = mock(DatabaseProvider.class);
+ RecordingObserver observer = new RecordingObserver();
+ when(handler.registerDatabaseOrThrow(identity, DatabaseType.MYSQL, "survival-primary"))
+ .thenReturn(provider);
+
+ boundApi(handler, identity, observer)
+ .registerDatabaseOrThrow(DatabaseType.MYSQL, "survival-primary");
+
+ RecordingObservation observation = observer.single();
+ assertEquals("serverfeatures", observation.context.pluginId());
+ assertEquals(OwnerScope.of("serverfeatures"), observation.context.ownerScope());
+ assertEquals(DatabaseType.MYSQL, observation.context.databaseType());
+ assertEquals("database.register", observation.context.operation());
+ assertEquals(1, observation.succeeded);
+ assertNull(observation.failure);
+ }
+
+ @Test
+ void scopedRegistrationExposesPublicScopeInsteadOfUniqueInternalRegistrationScope() {
+ DataProviderHandler handler = mock(DataProviderHandler.class);
+ PluginIdentity identity = identity("dataregistry");
+ DatabaseProvider provider = mock(DatabaseProvider.class);
+ RecordingObserver observer = new RecordingObserver();
+ OwnerScope publicScope = OwnerScope.of("profiles");
+ when(handler.registerDatabaseForScopeOrThrow(
+ eq(identity),
+ any(OwnerScope.class),
+ eq(DatabaseType.REDIS),
+ eq("cache")
+ )).thenReturn(provider);
+
+ DataProviderScope scope = boundApi(handler, identity, observer).scope(publicScope);
+ scope.registerDatabaseOrThrow(DatabaseType.REDIS, "cache");
+
+ ArgumentCaptor internalScope = ArgumentCaptor.forClass(OwnerScope.class);
+ verify(handler).registerDatabaseForScopeOrThrow(
+ eq(identity),
+ internalScope.capture(),
+ eq(DatabaseType.REDIS),
+ eq("cache")
+ );
+ assertNotEquals(publicScope, internalScope.getValue());
+ assertEquals(publicScope, observer.single().context.ownerScope());
+ }
+
+ @Test
+ void asynchronousDataAccessObservationFinishesWithTheReturnedFuture() {
+ DataProviderHandler handler = mock(DataProviderHandler.class);
+ PluginIdentity identity = identity("serverfeatures");
+ RelationalDatabaseProvider provider = mock(RelationalDatabaseProvider.class);
+ RelationalDataAccess access = mock(RelationalDataAccess.class);
+ CompletableFuture