("openApiGenerate") {
+ inputs.dir(templatesDir)
+ inputs.dir(specsDir)
+ actions.addFirst { delete { delete(generatedDir) } }
+}
+
+tasks.named("javadoc") { dependsOn("jandex") }
diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts
index 11705713b38..857c23bc4bc 100644
--- a/bom/build.gradle.kts
+++ b/bom/build.gradle.kts
@@ -30,6 +30,7 @@ dependencies {
api(project(":polaris-api-iceberg-service"))
api(project(":polaris-api-management-model"))
api(project(":polaris-api-management-service"))
+ api(project(":polaris-api-metrics-reports-service"))
api(project(":polaris-container-spec-helper"))
api(project(":polaris-azurite-testcontainer"))
@@ -99,6 +100,7 @@ dependencies {
api(project(":polaris-extensions-federation-bigquery"))
api(project(":polaris-extensions-federation-hadoop"))
api(project(":polaris-extensions-federation-hive"))
+ api(project(":polaris-extensions-metrics-reports-spi"))
api(project(":polaris-hms-testcontainer"))
api(project(":polaris-spark-3.5_2.12"))
@@ -107,6 +109,7 @@ dependencies {
api(project(":polaris-spark-3.5_2.13"))
api(project(":polaris-spark-4.0_2.13"))
}
+ api(project(":polaris-extensions-metrics-reports"))
api(project(":polaris-admin"))
api(project(":polaris-runtime-common"))
diff --git a/extensions/auth/ranger/src/intTest/resources/authz_it_tests/dev_polaris.json b/extensions/auth/ranger/src/intTest/resources/authz_it_tests/dev_polaris.json
index 701746f589b..057c5317b80 100644
--- a/extensions/auth/ranger/src/intTest/resources/authz_it_tests/dev_polaris.json
+++ b/extensions/auth/ranger/src/intTest/resources/authz_it_tests/dev_polaris.json
@@ -307,11 +307,12 @@
{ "itemId": 28, "name": "table-create", "label": "Table Create", "category": "CREATE", "impliedGrants": [ "table-list" ] },
{ "itemId": 29, "name": "table-drop", "label": "Table Drop", "category": "DELETE" },
{ "itemId": 30, "name": "table-list", "label": "Table List", "category": "READ" },
- { "itemId": 31, "name": "table-data-read", "label": "Table Data Read", "category": "READ", "impliedGrants": [ "table-list", "table-properties-read" ] },
+ { "itemId": 31, "name": "table-data-read", "label": "Table Data Read", "category": "READ", "impliedGrants": [ "table-list", "table-properties-read", "table-metrics-read" ] },
{ "itemId": 32, "name": "table-data-write", "label": "Table Data Write", "category": "UPDATE",
"impliedGrants": [
"table-list",
"table-data-read",
+ "table-metrics-read",
"table-properties-read",
"table-properties-set",
"table-properties-remove",
@@ -338,6 +339,7 @@
"table-create",
"table-drop",
"table-list",
+ "table-metrics-read",
"table-properties-read",
"table-properties-write",
"table-properties-set",
@@ -423,6 +425,7 @@
"table-statistics-remove"
]
},
+ { "itemId": 70, "name": "table-metrics-read", "label": "Table Metrics Read", "category": "READ", "impliedGrants": [ "table-list" ] },
{ "itemId": 56, "name": "view-create", "label": "View Create", "category": "CREATE", "impliedGrants": [ "view-list" ] },
{ "itemId": 57, "name": "view-drop", "label": "View Drop", "category": "DELETE" },
diff --git a/extensions/auth/ranger/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisOperationSemantics.java b/extensions/auth/ranger/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisOperationSemantics.java
index f8436d9024b..56eded02324 100644
--- a/extensions/auth/ranger/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisOperationSemantics.java
+++ b/extensions/auth/ranger/src/main/java/org/apache/polaris/extension/auth/ranger/RangerPolarisOperationSemantics.java
@@ -86,6 +86,7 @@ enum ResolvedPathRooting {
private static final String TABLE_WRITE_PROPERTIES = "table-properties-write";
private static final String TABLE_READ_DATA = "table-data-read";
private static final String TABLE_WRITE_DATA = "table-data-write";
+ private static final String TABLE_READ_METRICS = "table-metrics-read";
private static final String TABLE_ATTACH_POLICY = "table-policy-attach";
private static final String TABLE_DETACH_POLICY = "table-policy-detach";
private static final String TABLE_ASSIGN_UUID = "table-uuid-assign";
@@ -254,6 +255,10 @@ enum ResolvedPathRooting {
PolarisAuthorizableOperation.REPORT_READ_METRICS,
new RangerPolarisOperationSemantics(
toSet(TABLE_READ_DATA), null, ResolvedPathRooting.ROOT));
+ RBAC_SEMANTICS_BY_OPERATION.put(
+ PolarisAuthorizableOperation.LIST_TABLE_METRICS,
+ new RangerPolarisOperationSemantics(
+ toSet(TABLE_READ_METRICS), null, ResolvedPathRooting.ROOT));
RBAC_SEMANTICS_BY_OPERATION.put(
PolarisAuthorizableOperation.REPORT_WRITE_METRICS,
new RangerPolarisOperationSemantics(
diff --git a/extensions/auth/ranger/src/test/resources/authz_tests/dev_polaris.json b/extensions/auth/ranger/src/test/resources/authz_tests/dev_polaris.json
index f55a3286595..a7b255fdaf2 100644
--- a/extensions/auth/ranger/src/test/resources/authz_tests/dev_polaris.json
+++ b/extensions/auth/ranger/src/test/resources/authz_tests/dev_polaris.json
@@ -307,11 +307,12 @@
{ "itemId": 28, "name": "table-create", "label": "Table Create", "category": "CREATE", "impliedGrants": [ "table-list" ] },
{ "itemId": 29, "name": "table-drop", "label": "Table Drop", "category": "DELETE" },
{ "itemId": 30, "name": "table-list", "label": "Table List", "category": "READ" },
- { "itemId": 31, "name": "table-data-read", "label": "Table Data Read", "category": "READ", "impliedGrants": [ "table-list", "table-properties-read" ] },
+ { "itemId": 31, "name": "table-data-read", "label": "Table Data Read", "category": "READ", "impliedGrants": [ "table-list", "table-properties-read", "table-metrics-read" ] },
{ "itemId": 32, "name": "table-data-write", "label": "Table Data Write", "category": "UPDATE",
"impliedGrants": [
"table-list",
"table-data-read",
+ "table-metrics-read",
"table-properties-read",
"table-properties-set",
"table-properties-remove",
@@ -338,6 +339,7 @@
"table-create",
"table-drop",
"table-list",
+ "table-metrics-read",
"table-properties-read",
"table-properties-write",
"table-properties-set",
@@ -423,6 +425,7 @@
"table-statistics-remove"
]
},
+ { "itemId": 70, "name": "table-metrics-read", "label": "Table Metrics Read", "category": "READ", "impliedGrants": [ "table-list" ] },
{ "itemId": 56, "name": "view-create", "label": "View Create", "category": "CREATE", "impliedGrants": [ "view-list" ] },
{ "itemId": 57, "name": "view-drop", "label": "View Drop", "category": "DELETE" },
diff --git a/extensions/metrics-reports/base/build.gradle.kts b/extensions/metrics-reports/base/build.gradle.kts
new file mode 100644
index 00000000000..906697f7175
--- /dev/null
+++ b/extensions/metrics-reports/base/build.gradle.kts
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ id("polaris-server")
+ id("org.kordamp.gradle.jandex")
+}
+
+dependencies {
+ implementation(project(":polaris-core"))
+ implementation(project(":polaris-api-metrics-reports-service"))
+ implementation(project(":polaris-extensions-metrics-reports-spi"))
+
+ implementation(platform(libs.iceberg.bom))
+ implementation("org.apache.iceberg:iceberg-api")
+
+ implementation(libs.jakarta.enterprise.cdi.api)
+ implementation(libs.jakarta.inject.api)
+ implementation(libs.jakarta.ws.rs.api)
+ implementation(libs.guava)
+ implementation(libs.smallrye.common.annotation)
+ implementation(libs.slf4j.api)
+
+ compileOnly(libs.jspecify)
+
+ testImplementation(platform(libs.junit.bom))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation(libs.assertj.core)
+ testImplementation(libs.mockito.core)
+
+ // Provides jakarta.ws.rs.ext.RuntimeDelegate needed to build Response objects in plain unit tests
+ testRuntimeOnly(enforcedPlatform(libs.quarkus.bom))
+ testRuntimeOnly("io.quarkus.resteasy.reactive:resteasy-reactive")
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/reporting/DefaultMetricsReporter.java b/extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/LoggingMetricsReporter.java
similarity index 66%
rename from runtime/service/src/main/java/org/apache/polaris/service/reporting/DefaultMetricsReporter.java
rename to extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/LoggingMetricsReporter.java
index e58f740c3c0..f1d699226db 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/reporting/DefaultMetricsReporter.java
+++ b/extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/LoggingMetricsReporter.java
@@ -16,37 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.polaris.service.reporting;
+package org.apache.polaris.extension.metrics.reports;
-import com.google.common.annotations.VisibleForTesting;
import io.smallrye.common.annotation.Identifier;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.Instant;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.metrics.MetricsReport;
+import org.apache.polaris.extension.metrics.spi.IcebergMetricsReporter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Default implementation of {@link PolarisMetricsReporter} that logs metrics to the configured
- * logger.
+ * Log-only implementation of {@link IcebergMetricsReporter}.
*
- * This implementation is selected when {@code polaris.iceberg-metrics.reporting.type} is set to
- * {@code "default"} (the default value).
+ *
Selected when {@code polaris.iceberg-metrics.reporting.type} is set to {@code "log"}.
*
- *
By default, logging is disabled. To enable metrics logging, set the logger level for {@code
- * org.apache.polaris.service.reporting} to {@code INFO} in your logging configuration.
- *
- * @see PolarisMetricsReporter
+ *
Logging is at INFO level. Enable it by setting the logger level for {@code
+ * org.apache.polaris.extension.metrics.reports} to {@code INFO}.
*/
@ApplicationScoped
-@Identifier("default")
-public class DefaultMetricsReporter implements PolarisMetricsReporter {
- private static final Logger LOGGER = LoggerFactory.getLogger(DefaultMetricsReporter.class);
-
- private final ReportConsumer reportConsumer;
+@Identifier("log")
+public class LoggingMetricsReporter implements IcebergMetricsReporter {
+ private static final Logger LOGGER = LoggerFactory.getLogger(LoggingMetricsReporter.class);
- /** Functional interface for consuming metrics reports with timestamp. */
@FunctionalInterface
interface ReportConsumer {
void accept(
@@ -58,15 +51,15 @@ void accept(
Instant receivedTimestamp);
}
- /** Creates a new DefaultMetricsReporter that logs metrics to the class logger. */
- public DefaultMetricsReporter() {
+ private final ReportConsumer reportConsumer;
+
+ public LoggingMetricsReporter() {
this(
(catalogName, catalogId, table, tableId, metricsReport, receivedTimestamp) ->
LOGGER.info("{}.{} (ts={}): {}", catalogName, table, receivedTimestamp, metricsReport));
}
- @VisibleForTesting
- DefaultMetricsReporter(ReportConsumer reportConsumer) {
+ LoggingMetricsReporter(ReportConsumer reportConsumer) {
this.reportConsumer = reportConsumer;
}
@@ -80,4 +73,9 @@ public void reportMetric(
Instant receivedTimestamp) {
reportConsumer.accept(catalogName, catalogId, table, tableId, metricsReport, receivedTimestamp);
}
+
+ /** Backward-compatible alias for {@code "log"}; redirects the legacy {@code "default"} type. */
+ @ApplicationScoped
+ @Identifier("default")
+ public static class DefaultMetricsReporter extends LoggingMetricsReporter {}
}
diff --git a/extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/MetricsReportsService.java b/extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/MetricsReportsService.java
new file mode 100644
index 00000000000..120e0aebe56
--- /dev/null
+++ b/extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/MetricsReportsService.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.metrics.reports;
+
+import com.google.common.annotations.Beta;
+import jakarta.enterprise.context.RequestScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.SecurityContext;
+import java.util.Arrays;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NotFoundException;
+import org.apache.polaris.core.auth.PolarisAuthorizableOperation;
+import org.apache.polaris.core.auth.PolarisAuthorizer;
+import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.catalog.PolarisCatalogHelpers;
+import org.apache.polaris.core.context.RealmContext;
+import org.apache.polaris.core.entity.PolarisEntitySubType;
+import org.apache.polaris.core.entity.PolarisEntityType;
+import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
+import org.apache.polaris.core.persistence.resolver.PolarisResolutionManifest;
+import org.apache.polaris.core.persistence.resolver.ResolutionManifestFactory;
+import org.apache.polaris.core.persistence.resolver.ResolvedPathKey;
+import org.apache.polaris.core.persistence.resolver.ResolverPath;
+import org.apache.polaris.core.persistence.resolver.ResolverStatus;
+import org.apache.polaris.core.rest.NamespaceUtils;
+import org.apache.polaris.service.metrics.api.PolarisCatalogsApiService;
+import org.jspecify.annotations.NonNull;
+
+/**
+ * Service implementation for the Metrics Reports API.
+ *
+ *
Resolves catalog/namespace/table names to internal IDs and performs authorization. In this
+ * release the read path returns HTTP 501 Not Implemented; durable query backing is provided by the
+ * {@code polaris-extensions-metrics-reports-jdbc} extension in a follow-up release.
+ *
+ *
TODO (#4756): wire durable metrics query provider when the extension is installed.
+ */
+@Beta
+@RequestScoped
+public class MetricsReportsService implements PolarisCatalogsApiService {
+
+ private final PolarisAuthorizer authorizer;
+ private final PolarisPrincipal polarisPrincipal;
+ private final ResolutionManifestFactory resolutionManifestFactory;
+
+ @Inject
+ public MetricsReportsService(
+ @NonNull PolarisAuthorizer authorizer,
+ @NonNull PolarisPrincipal polarisPrincipal,
+ @NonNull ResolutionManifestFactory resolutionManifestFactory) {
+ this.authorizer = authorizer;
+ this.polarisPrincipal = polarisPrincipal;
+ this.resolutionManifestFactory = resolutionManifestFactory;
+ }
+
+ @Override
+ public Response listTableMetrics(
+ String catalogName,
+ String namespace,
+ String table,
+ String metricType,
+ String pageToken,
+ Integer pageSize,
+ Long snapshotId,
+ String principalName,
+ Long timestampFrom,
+ Long timestampTo,
+ RealmContext realmContext,
+ SecurityContext securityContext) {
+
+ Namespace ns = decodeNamespace(namespace);
+ TableIdentifier identifier = TableIdentifier.of(ns, table);
+
+ resolveAndAuthorizeTableMetrics(catalogName, identifier);
+
+ return Response.status(Response.Status.NOT_IMPLEMENTED)
+ .entity(
+ "Durable metrics query is not available in this deployment. "
+ + "Install the polaris-extensions-metrics-reports-jdbc extension to enable it.")
+ .build();
+ }
+
+ private void resolveAndAuthorizeTableMetrics(String catalogName, TableIdentifier identifier) {
+ PolarisResolutionManifest manifest =
+ resolutionManifestFactory.createResolutionManifest(polarisPrincipal, catalogName);
+ manifest.addPassthroughPath(
+ new ResolverPath(
+ Arrays.asList(identifier.namespace().levels()), PolarisEntityType.NAMESPACE));
+ manifest.addPassthroughPath(
+ new ResolverPath(
+ PolarisCatalogHelpers.tableIdentifierToList(identifier), PolarisEntityType.TABLE_LIKE));
+ ResolverStatus status = manifest.resolveAll();
+
+ if (status.getStatus() == ResolverStatus.StatusEnum.ENTITY_COULD_NOT_BE_RESOLVED) {
+ throw new NotFoundException(
+ "TopLevelEntity of type %s does not exist: %s",
+ status.getFailedToResolvedEntityType(), status.getFailedToResolvedEntityName());
+ }
+ if (status.getStatus() == ResolverStatus.StatusEnum.PATH_COULD_NOT_BE_FULLY_RESOLVED) {
+ throw new NotFoundException("Table not found: %s", identifier);
+ }
+
+ PolarisResolvedPathWrapper tableWrapper =
+ manifest.getResolvedPath(
+ ResolvedPathKey.ofTableLike(identifier), PolarisEntitySubType.ANY_SUBTYPE, true);
+
+ if (tableWrapper == null) {
+ throw new NotFoundException("Table not found: %s", identifier);
+ }
+
+ authorizer.authorizeOrThrow(
+ polarisPrincipal,
+ manifest.getAllActivatedCatalogRoleAndPrincipalRoles(),
+ PolarisAuthorizableOperation.LIST_TABLE_METRICS,
+ tableWrapper,
+ null);
+ }
+
+ private static Namespace decodeNamespace(String encodedNamespace) {
+ if (encodedNamespace == null || encodedNamespace.isEmpty()) {
+ throw new IllegalArgumentException("namespace must not be empty");
+ }
+ return NamespaceUtils.splitNamespace(
+ encodedNamespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
+ }
+}
diff --git a/extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/NoOpMetricsReporter.java b/extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/NoOpMetricsReporter.java
new file mode 100644
index 00000000000..7fa4fcab12f
--- /dev/null
+++ b/extensions/metrics-reports/base/src/main/java/org/apache/polaris/extension/metrics/reports/NoOpMetricsReporter.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.metrics.reports;
+
+import io.smallrye.common.annotation.Identifier;
+import jakarta.enterprise.context.ApplicationScoped;
+import java.time.Instant;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.metrics.MetricsReport;
+import org.apache.polaris.extension.metrics.spi.IcebergMetricsReporter;
+
+/**
+ * No-op implementation of {@link IcebergMetricsReporter} that silently discards all metrics.
+ *
+ *
Selected when {@code polaris.iceberg-metrics.reporting.type} is set to {@code "no-op"}.
+ */
+@ApplicationScoped
+@Identifier("no-op")
+public class NoOpMetricsReporter implements IcebergMetricsReporter {
+
+ @Override
+ public void reportMetric(
+ String catalogName,
+ long catalogId,
+ TableIdentifier table,
+ long tableId,
+ MetricsReport metricsReport,
+ Instant receivedTimestamp) {}
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/reporting/DefaultMetricsReporterTest.java b/extensions/metrics-reports/base/src/test/java/org/apache/polaris/extension/metrics/reports/LoggingMetricsReporterTest.java
similarity index 85%
rename from runtime/service/src/test/java/org/apache/polaris/service/reporting/DefaultMetricsReporterTest.java
rename to extensions/metrics-reports/base/src/test/java/org/apache/polaris/extension/metrics/reports/LoggingMetricsReporterTest.java
index 2240f41c921..cc47c55d9fd 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/reporting/DefaultMetricsReporterTest.java
+++ b/extensions/metrics-reports/base/src/test/java/org/apache/polaris/extension/metrics/reports/LoggingMetricsReporterTest.java
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.polaris.service.reporting;
+package org.apache.polaris.extension.metrics.reports;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -26,13 +26,13 @@
import org.apache.iceberg.metrics.MetricsReport;
import org.junit.jupiter.api.Test;
-public class DefaultMetricsReporterTest {
+public class LoggingMetricsReporterTest {
@Test
void testLogging() {
- DefaultMetricsReporter.ReportConsumer mockConsumer =
- mock(DefaultMetricsReporter.ReportConsumer.class);
- DefaultMetricsReporter reporter = new DefaultMetricsReporter(mockConsumer);
+ LoggingMetricsReporter.ReportConsumer mockConsumer =
+ mock(LoggingMetricsReporter.ReportConsumer.class);
+ LoggingMetricsReporter reporter = new LoggingMetricsReporter(mockConsumer);
String warehouse = "testWarehouse";
long catalogId = 12345L;
TableIdentifier table = TableIdentifier.of("testNamespace", "testTable");
diff --git a/extensions/metrics-reports/base/src/test/java/org/apache/polaris/extension/metrics/reports/MetricsReportsServiceTest.java b/extensions/metrics-reports/base/src/test/java/org/apache/polaris/extension/metrics/reports/MetricsReportsServiceTest.java
new file mode 100644
index 00000000000..a1b636525e8
--- /dev/null
+++ b/extensions/metrics-reports/base/src/test/java/org/apache/polaris/extension/metrics/reports/MetricsReportsServiceTest.java
@@ -0,0 +1,245 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.metrics.reports;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import jakarta.ws.rs.ForbiddenException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.SecurityContext;
+import java.util.List;
+import java.util.Set;
+import org.apache.iceberg.exceptions.NotFoundException;
+import org.apache.polaris.core.auth.PolarisAuthorizableOperation;
+import org.apache.polaris.core.auth.PolarisAuthorizer;
+import org.apache.polaris.core.auth.PolarisPrincipal;
+import org.apache.polaris.core.context.RealmContext;
+import org.apache.polaris.core.entity.PolarisEntitySubType;
+import org.apache.polaris.core.entity.PolarisEntityType;
+import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
+import org.apache.polaris.core.persistence.resolver.PolarisResolutionManifest;
+import org.apache.polaris.core.persistence.resolver.ResolutionManifestFactory;
+import org.apache.polaris.core.persistence.resolver.ResolvedPathKey;
+import org.apache.polaris.core.persistence.resolver.ResolverPath;
+import org.apache.polaris.core.persistence.resolver.ResolverStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link MetricsReportsService}.
+ *
+ *
The read path currently returns 501 Not Implemented pending the durable extension (#4756).
+ * These tests cover authorization, resolution error paths, and input validation.
+ */
+class MetricsReportsServiceTest {
+
+ private static final String CATALOG = "test-catalog";
+ private static final String NAMESPACE = "dbschema";
+ private static final String TABLE = "events";
+
+ private PolarisAuthorizer authorizer;
+ private PolarisResolutionManifest manifest;
+ private PolarisPrincipal principal;
+ private ResolutionManifestFactory factory;
+ private MetricsReportsService service;
+ private RealmContext realmContext;
+ private SecurityContext securityContext;
+
+ @BeforeEach
+ void setUp() {
+ authorizer = mock(PolarisAuthorizer.class);
+ principal = mock(PolarisPrincipal.class);
+
+ PolarisResolvedPathWrapper tableWrapper = mock(PolarisResolvedPathWrapper.class);
+ manifest = mock(PolarisResolutionManifest.class);
+ factory = mock(ResolutionManifestFactory.class);
+ realmContext = mock(RealmContext.class);
+ securityContext = mock(SecurityContext.class);
+
+ when(manifest.resolveAll()).thenReturn(new ResolverStatus(ResolverStatus.StatusEnum.SUCCESS));
+ when(manifest.getResolvedPath(
+ any(ResolvedPathKey.class), eq(PolarisEntitySubType.ANY_SUBTYPE), eq(true)))
+ .thenReturn(tableWrapper);
+ when(manifest.getAllActivatedCatalogRoleAndPrincipalRoles()).thenReturn(Set.of());
+ when(factory.createResolutionManifest(eq(principal), eq(CATALOG))).thenReturn(manifest);
+ doNothing()
+ .when(authorizer)
+ .authorizeOrThrow(
+ any(PolarisPrincipal.class),
+ any(Set.class),
+ any(PolarisAuthorizableOperation.class),
+ any(PolarisResolvedPathWrapper.class),
+ (PolarisResolvedPathWrapper) isNull());
+
+ service = new MetricsReportsService(authorizer, principal, factory);
+ }
+
+ @Test
+ void authorizedRequestReturnsNotImplemented() {
+ Response response =
+ service.listTableMetrics(
+ CATALOG,
+ NAMESPACE,
+ TABLE,
+ "scan",
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ realmContext,
+ securityContext);
+
+ assertThat(response.getStatus()).isEqualTo(Response.Status.NOT_IMPLEMENTED.getStatusCode());
+ }
+
+ @Test
+ void unauthorizedRequestThrowsForbiddenException() {
+ doThrow(new ForbiddenException("denied"))
+ .when(authorizer)
+ .authorizeOrThrow(
+ any(PolarisPrincipal.class),
+ any(Set.class),
+ eq(PolarisAuthorizableOperation.LIST_TABLE_METRICS),
+ any(PolarisResolvedPathWrapper.class),
+ (PolarisResolvedPathWrapper) isNull());
+
+ assertThatThrownBy(
+ () ->
+ service.listTableMetrics(
+ CATALOG,
+ NAMESPACE,
+ TABLE,
+ "scan",
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ realmContext,
+ securityContext))
+ .isInstanceOf(ForbiddenException.class);
+ }
+
+ @Test
+ void tableNotFoundThrowsNotFoundException() {
+ when(manifest.getResolvedPath(
+ any(ResolvedPathKey.class), eq(PolarisEntitySubType.ANY_SUBTYPE), eq(true)))
+ .thenReturn(null);
+
+ assertThatThrownBy(
+ () ->
+ service.listTableMetrics(
+ CATALOG,
+ NAMESPACE,
+ TABLE,
+ "scan",
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ realmContext,
+ securityContext))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessageContaining(TABLE);
+ }
+
+ @Test
+ void catalogNotFoundPropagatesNotFoundException() {
+ when(manifest.resolveAll()).thenReturn(new ResolverStatus(PolarisEntityType.CATALOG, CATALOG));
+
+ assertThatThrownBy(
+ () ->
+ service.listTableMetrics(
+ CATALOG,
+ NAMESPACE,
+ TABLE,
+ "scan",
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ realmContext,
+ securityContext))
+ .isInstanceOf(NotFoundException.class)
+ .hasMessageContaining(CATALOG);
+ }
+
+ @Test
+ void pathNotFoundPropagatesNotFoundException() {
+ ResolverPath failedPath = new ResolverPath(List.of(NAMESPACE), PolarisEntityType.NAMESPACE);
+ when(manifest.resolveAll()).thenReturn(new ResolverStatus(failedPath, 0));
+
+ assertThatThrownBy(
+ () ->
+ service.listTableMetrics(
+ CATALOG,
+ NAMESPACE,
+ TABLE,
+ "scan",
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ realmContext,
+ securityContext))
+ .isInstanceOf(NotFoundException.class);
+ }
+
+ @Test
+ void multiLevelNamespaceIsSplitCorrectly() {
+ // JAX-RS decodes %1F -> U+001F before injection; the service must split correctly.
+ // "dbschema" represents namespace ["db", "schema"].
+ String encodedTwoLevel = "dbschema";
+ when(factory.createResolutionManifest(eq(principal), eq(CATALOG))).thenReturn(manifest);
+
+ Response response =
+ service.listTableMetrics(
+ CATALOG,
+ encodedTwoLevel,
+ TABLE,
+ "scan",
+ null,
+ 10,
+ null,
+ null,
+ null,
+ null,
+ realmContext,
+ securityContext);
+
+ assertThat(response.getStatus()).isEqualTo(Response.Status.NOT_IMPLEMENTED.getStatusCode());
+ }
+}
diff --git a/extensions/metrics-reports/spi/build.gradle.kts b/extensions/metrics-reports/spi/build.gradle.kts
new file mode 100644
index 00000000000..b1b0a1898ff
--- /dev/null
+++ b/extensions/metrics-reports/spi/build.gradle.kts
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ id("polaris-server")
+}
+
+dependencies {
+ implementation(platform(libs.iceberg.bom))
+ implementation("org.apache.iceberg:iceberg-api")
+ implementation(libs.guava)
+}
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/reporting/PolarisMetricsReporter.java b/extensions/metrics-reports/spi/src/main/java/org/apache/polaris/extension/metrics/spi/IcebergMetricsReporter.java
similarity index 60%
rename from runtime/service/src/main/java/org/apache/polaris/service/reporting/PolarisMetricsReporter.java
rename to extensions/metrics-reports/spi/src/main/java/org/apache/polaris/extension/metrics/spi/IcebergMetricsReporter.java
index c621be2a5d8..585bcd07014 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/reporting/PolarisMetricsReporter.java
+++ b/extensions/metrics-reports/spi/src/main/java/org/apache/polaris/extension/metrics/spi/IcebergMetricsReporter.java
@@ -16,38 +16,35 @@
* specific language governing permissions and limitations
* under the License.
*/
-package org.apache.polaris.service.reporting;
+package org.apache.polaris.extension.metrics.spi;
+import com.google.common.annotations.Beta;
import java.time.Instant;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.metrics.MetricsReport;
/**
- * SPI interface for reporting Iceberg metrics received by Polaris.
+ * SPI for reporting Iceberg metrics received by Polaris.
*
- *
Implementations can be used to send metrics to external systems for analysis and monitoring.
- * Custom implementations can be annotated with appropriate {@code Quarkus} scope and {@link
- * io.smallrye.common.annotation.Identifier @Identifier("my-reporter-type")} for CDI discovery.
+ *
Implementations receive a resolved table context (catalog name/id and table name/id) along
+ * with the raw Iceberg {@link MetricsReport}. Custom implementations should be annotated with the
+ * appropriate CDI scope and {@code @Identifier("my-type")} for selection via the {@code
+ * polaris.iceberg-metrics.reporting.type} configuration property.
*
- *
The implementation to use is selected via the {@code polaris.iceberg-metrics.reporting.type}
- * configuration property, which defaults to {@code "default"}.
- *
- *
Implementations can inject other CDI beans for context.
- *
- * @see DefaultMetricsReporter
- * @see MetricsReportingConfiguration
+ *
This interface is intentionally runtime/framework-agnostic. CDI and configuration concerns
+ * belong in the implementing class, not here.
*/
-public interface PolarisMetricsReporter {
+@Beta
+public interface IcebergMetricsReporter {
/**
- * Reports an Iceberg metrics report for a specific table.
+ * Reports an Iceberg metrics report for a resolved table.
*
* @param catalogName the name of the catalog containing the table
* @param catalogId the internal Polaris ID of the catalog
* @param table the identifier of the table the metrics are for
* @param tableId the internal Polaris ID of the table entity
- * @param metricsReport the Iceberg metrics report (e.g., {@link
- * org.apache.iceberg.metrics.ScanReport} or {@link org.apache.iceberg.metrics.CommitReport})
+ * @param metricsReport the Iceberg metrics report
* @param receivedTimestamp the timestamp when the metrics were received by Polaris
*/
void reportMetric(
diff --git a/gradle/projects.main.properties b/gradle/projects.main.properties
index cae568b92e2..a907b82e8e1 100644
--- a/gradle/projects.main.properties
+++ b/gradle/projects.main.properties
@@ -23,6 +23,7 @@ polaris-core=polaris-core
polaris-api-iceberg-service=api/iceberg-service
polaris-api-management-model=api/management-model
polaris-api-management-service=api/management-service
+polaris-api-metrics-reports-service=api/metrics-reports-service
polaris-api-catalog-service=api/polaris-catalog-service
polaris-runtime-defaults=runtime/defaults
polaris-runtime-service=runtime/service
@@ -49,6 +50,8 @@ polaris-extensions-federation-hive=extensions/federation/hive
polaris-extensions-federation-bigquery=extensions/federation/bigquery
polaris-extensions-auth-opa=extensions/auth/opa
polaris-extensions-auth-ranger=extensions/auth/ranger
+polaris-extensions-metrics-reports-spi=extensions/metrics-reports/spi
+polaris-extensions-metrics-reports=extensions/metrics-reports/base
polaris-config-docs-annotations=tools/config-docs/annotations
polaris-config-docs-generator=tools/config-docs/generator
diff --git a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java
index cc711cc646a..cdb408bc7a6 100644
--- a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java
+++ b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java
@@ -57,9 +57,6 @@
import org.apache.polaris.core.persistence.PolicyMappingAlreadyExistsException;
import org.apache.polaris.core.persistence.PrincipalSecretsGenerator;
import org.apache.polaris.core.persistence.RetryOnConcurrencyException;
-import org.apache.polaris.core.persistence.metrics.CommitMetricsRecord;
-import org.apache.polaris.core.persistence.metrics.MetricsPersistence;
-import org.apache.polaris.core.persistence.metrics.ScanMetricsRecord;
import org.apache.polaris.core.persistence.pagination.EntityIdToken;
import org.apache.polaris.core.persistence.pagination.Page;
import org.apache.polaris.core.persistence.pagination.PageToken;
@@ -70,21 +67,18 @@
import org.apache.polaris.core.storage.PolarisStorageIntegration;
import org.apache.polaris.core.storage.StorageLocation;
import org.apache.polaris.persistence.relational.jdbc.models.EntityNameLookupRecordConverter;
-import org.apache.polaris.persistence.relational.jdbc.models.ModelCommitMetricsReport;
import org.apache.polaris.persistence.relational.jdbc.models.ModelEntity;
import org.apache.polaris.persistence.relational.jdbc.models.ModelEvent;
import org.apache.polaris.persistence.relational.jdbc.models.ModelGrantRecord;
import org.apache.polaris.persistence.relational.jdbc.models.ModelPolicyMappingRecord;
import org.apache.polaris.persistence.relational.jdbc.models.ModelPrincipalAuthenticationData;
-import org.apache.polaris.persistence.relational.jdbc.models.ModelScanMetricsReport;
import org.apache.polaris.persistence.relational.jdbc.models.SchemaVersion;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public class JdbcBasePersistenceImpl
- implements BasePersistence, IntegrationPersistence, MetricsPersistence {
+public class JdbcBasePersistenceImpl implements BasePersistence, IntegrationPersistence {
private static final Logger LOGGER = LoggerFactory.getLogger(JdbcBasePersistenceImpl.class);
@@ -1337,59 +1331,4 @@ public void persistStorageIntegrationIfNeeded(
private interface QueryAction {
Integer apply(Connection connection, QueryGenerator.PreparedQuery query) throws SQLException;
}
-
- // ============================================================================
- // MetricsPersistence Implementation
- // ============================================================================
-
- /** Returns the datasource operations to use for metrics persistence. */
- private DatasourceOperations getMetricsDatasource() {
- return datasourceOperations;
- }
-
- @Override
- public void writeScanReport(@NonNull ScanMetricsRecord record) {
- ModelScanMetricsReport model = ModelScanMetricsReport.fromRecord(record, realmId);
- writeScanMetricsReport(model);
- }
-
- @Override
- public void writeCommitReport(@NonNull CommitMetricsRecord record) {
- ModelCommitMetricsReport model = ModelCommitMetricsReport.fromRecord(record, realmId);
- writeCommitMetricsReport(model);
- }
-
- // ========== Internal Metrics JDBC methods ==========
-
- private void writeScanMetricsReport(@NonNull ModelScanMetricsReport report) {
- DatasourceOperations metricsOps = getMetricsDatasource();
- try {
- PreparedQuery pq =
- QueryGenerator.generateInsertQuery(
- ModelScanMetricsReport.ALL_COLUMNS,
- ModelScanMetricsReport.TABLE_NAME,
- report.toMap(metricsOps.getDatabaseType()).values().stream().toList(),
- realmId);
- metricsOps.executeUpdate(pq);
- } catch (SQLException e) {
- throw new RuntimeException(
- String.format("Failed to write scan metrics report due to %s", e.getMessage()), e);
- }
- }
-
- private void writeCommitMetricsReport(@NonNull ModelCommitMetricsReport report) {
- DatasourceOperations metricsOps = getMetricsDatasource();
- try {
- PreparedQuery pq =
- QueryGenerator.generateInsertQuery(
- ModelCommitMetricsReport.ALL_COLUMNS,
- ModelCommitMetricsReport.TABLE_NAME,
- report.toMap(metricsOps.getDatabaseType()).values().stream().toList(),
- realmId);
- metricsOps.executeUpdate(pq);
- } catch (SQLException e) {
- throw new RuntimeException(
- String.format("Failed to write commit metrics report due to %s", e.getMessage()), e);
- }
- }
}
diff --git a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcMetaStoreManagerFactory.java b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcMetaStoreManagerFactory.java
index e88aac882af..57be471af31 100644
--- a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcMetaStoreManagerFactory.java
+++ b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcMetaStoreManagerFactory.java
@@ -252,7 +252,7 @@ public BasePersistence getOrCreateSession(RealmContext realmContext) {
@Override
public MetricsPersistence getOrCreateMetricsPersistence(RealmContext realmContext) {
- return createJdbcPersistence(realmContext);
+ return new MetricsPersistence() {};
}
@Override
diff --git a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/models/ModelCommitMetricsReport.java b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/models/ModelCommitMetricsReport.java
index 10dc193869e..47f777cdc86 100644
--- a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/models/ModelCommitMetricsReport.java
+++ b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/models/ModelCommitMetricsReport.java
@@ -20,9 +20,11 @@
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import org.apache.polaris.core.persistence.metrics.CommitMetricsRecord;
import org.apache.polaris.immutables.PolarisImmutable;
import org.apache.polaris.persistence.relational.jdbc.DatabaseType;
@@ -316,6 +318,64 @@ private static String toJsonString(Map map) {
}
}
+ private static Map parseMetadata(@Nullable String metadata) {
+ if (metadata == null || metadata.isBlank()) {
+ return Map.of();
+ }
+ try {
+ Map parsed =
+ OBJECT_MAPPER.readValue(
+ metadata, new tools.jackson.core.type.TypeReference