Skip to content

(feat) Implement metrics rest api - #4115

Open
obelix74 wants to merge 10 commits into
apache:mainfrom
obelix74:implement_metrics_rest_api
Open

(feat) Implement metrics rest api#4115
obelix74 wants to merge 10 commits into
apache:mainfrom
obelix74:implement_metrics_rest_api

Conversation

@obelix74

@obelix74 obelix74 commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

What / Why

Part of the 3-PR split of the Iceberg metrics work (agreed with EJ, Dmitri, Anand):

Depends on #5068. This branch is stacked on the PR0 branch, so until #5068 merges the diff here also shows PR0's changes. Review the commits from feat(metrics): add Table Metrics Reports query REST API (PR1) onward.

Changes

  • OpenAPI spec + generated module under extensions/metrics-reports/api (polaris-extensions-metrics-reports-api) — the metrics API ships as an optional, extension-scoped feature.
  • MetricsQuerySpi in extensions/metrics-reports/spi; a no-op default (NoOpMetricsQuery, @DefaultBean) in extensions/metrics-reports/base so the read path returns an empty page (HTTP 200) until a durable backend is installed.
  • Thin HTTP→SPI handler MetricsReportsService in runtime/service (resolves names→ids, authorizes, delegates to MetricsQuerySpi).
  • Read-path authorization in polaris-core: TABLE_READ_METRICS privilege + LIST_TABLE_METRICS operation, wired through the authorizer/RBAC and mirrored in the Ranger extension.

Testing

  • ./gradlew build and :polaris-runtime-service:intTest (Quarkus) pass.

@obelix74
obelix74 force-pushed the implement_metrics_rest_api branch from 1d37c50 to ac66c27 Compare April 10, 2026 14:26

@dimas-b dimas-b left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing this feature forward, @obelix74 !

The PR LGTM in general. Posting some comments about code organization, subject to discussion, of course.

Comment thread spec/metrics-reports-service.yml Outdated
Comment thread spec/metrics-reports-service.yml Outdated
Comment thread spec/metrics-reports-service.yml Outdated
Comment thread spec/metrics-reports-service.yml Outdated
Comment thread runtime/service/build.gradle.kts Outdated

@dimas-b dimas-b left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with one minor remaining comment 😅

@sungwy , @sneethiraj : FYI about the new AuthZ operation.

Comment thread gradle/projects.main.properties Outdated
Comment thread CHANGELOG.md Outdated
Comment thread spec/metrics-reports-service.yml Outdated
@obelix74
obelix74 requested a review from dimas-b April 16, 2026 16:08

@flyingImer flyingImer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The direction looks right to me

Two structural observations:

  • With this PR, MetricsPersistence grows from 2 write methods to 4 (read + write). It's marked @beta and the javadoc calls it a "Service Provider Interface." But it lives on BasePersistence, which only local DB backends implement. NoSqlMetaStoreManager and RemotePolarisMetaStoreManager go through empty BasePersistence implementations, so these methods are permanently no-op for them. Meanwhile, the actual SPI interfaces (PolarisMetricsReporter, PolarisMetricsManager) have no annotation at all. The @beta signal is on the wrong layer IIUC

  • The write path enters through PolarisMetricsManager on MetaStoreManager, but this read path bypasses that layer and goes straight to BasePersistence via callContext.getMetaStore(). If we want the metrics read API to work for non-JDBC backends, it would need a MetaStoreManager-level entry point, same as writes.

Not blocking on this. I think the question of where metrics persistence should sit architecturally is worth a discussion on dev@.

@obelix74

Copy link
Copy Markdown
Contributor Author

The direction looks right to me

Two structural observations:

  • With this PR, MetricsPersistence grows from 2 write methods to 4 (read + write). It's marked @beta and the javadoc calls it a "Service Provider Interface." But it lives on BasePersistence, which only local DB backends implement. NoSqlMetaStoreManager and RemotePolarisMetaStoreManager go through empty BasePersistence implementations, so these methods are permanently no-op for them. Meanwhile, the actual SPI interfaces (PolarisMetricsReporter, PolarisMetricsManager) have no annotation at all. The @beta signal is on the wrong layer IIUC
  • The write path enters through PolarisMetricsManager on MetaStoreManager, but this read path bypasses that layer and goes straight to BasePersistence via callContext.getMetaStore(). If we want the metrics read API to work for non-JDBC backends, it would need a MetaStoreManager-level entry point, same as writes.

Not blocking on this. I think the question of where metrics persistence should sit architecturally is worth a discussion on dev@.

Thank you. I have added @Beta annotation to PolarisMetricsManager and PolarisMetricsReporter.

About the second point, thank you. Would this mean a read method to PolarisMetricsManager and MetaStoreManager mirroring the write path? Should I do it in this PR or can this wait?

@flyingImer

Copy link
Copy Markdown
Collaborator

The direction looks right to me
Two structural observations:

  • With this PR, MetricsPersistence grows from 2 write methods to 4 (read + write). It's marked @beta and the javadoc calls it a "Service Provider Interface." But it lives on BasePersistence, which only local DB backends implement. NoSqlMetaStoreManager and RemotePolarisMetaStoreManager go through empty BasePersistence implementations, so these methods are permanently no-op for them. Meanwhile, the actual SPI interfaces (PolarisMetricsReporter, PolarisMetricsManager) have no annotation at all. The @beta signal is on the wrong layer IIUC
  • The write path enters through PolarisMetricsManager on MetaStoreManager, but this read path bypasses that layer and goes straight to BasePersistence via callContext.getMetaStore(). If we want the metrics read API to work for non-JDBC backends, it would need a MetaStoreManager-level entry point, same as writes.

Not blocking on this. I think the question of where metrics persistence should sit architecturally is worth a discussion on dev@.

Thank you. I have added @Beta annotation to PolarisMetricsManager and PolarisMetricsReporter.

About the second point, thank you. Would this mean a read method to PolarisMetricsManager and MetaStoreManager mirroring the write path? Should I do it in this PR or can this wait?

Thanks for adding @beta.

Reads should go through MetaStoreManager too, same as writes. If reads stay on BasePersistence, non-JDBC backends can't implement the read API at all. I'd prefer fixing that in this PR so the read path ships with the same layering as writes.

Separately, the persistence schema discussion on dev@ is still open. A follow-up issue linking to that thread would help track it.

@obelix74

Copy link
Copy Markdown
Contributor Author

Reads should go through MetaStoreManager too, same as writes. If reads stay on BasePersistence, non-JDBC backends can't implement the read API at all. I'd prefer fixing that in this PR so the read path ships with the same layering as writes.

Separately, the persistence schema discussion on dev@ is still open. A follow-up issue linking to that thread would help track it.

Pushed a commit (and rebased against updated main). listScanMetrics and listCommitMetrics are now on PolarisMetricsManager (and therefore MetaStoreManager), following the same pattern as the write methods. MetricsReportsService now injects PolarisMetaStoreManager and routes reads through it rather than calling callContext.getMetaStore() directly.

For the persistence schema discussion — I'll open a follow-up issue linking to the dev@ thread once there's a message to reference. Happy to do that now if you can share the thread link (I don't have it handy). Please let me know.

@obelix74
obelix74 requested a review from flyingImer April 28, 2026 18:00

@dimas-b dimas-b left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metrics API story LGTM 👍

I still have more general concerns related to SPI design and service wiring, but they are not specific to this feature.

Thanks for working on this @obelix74 !

@github-project-automation github-project-automation Bot moved this from PRs In Progress to Ready to merge in Basic Kanban Board May 15, 2026

@flyingImer flyingImer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you planning to merge as-is now that Dmitri approved, or is there another round? Asking because the May 7 metrics sync landed on a few directional items that touch the schema and SPI shape here. Left some questions inline.

@obelix74
obelix74 requested a review from dimas-b May 19, 2026 15:26
dimas-b
dimas-b previously approved these changes May 19, 2026
@dimas-b

dimas-b commented May 26, 2026

Copy link
Copy Markdown
Contributor

@obelix74 : it looks like this PR got a lot of conflicts 🤷

@obelix74

Copy link
Copy Markdown
Contributor Author

@obelix74 : it looks like this PR got a lot of conflicts 🤷

Resolved all conflicts and push. Rebased against main.

dimas-b
dimas-b previously approved these changes May 26, 2026

@flyingImer flyingImer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for continuing to push this forward. The direction still looks good to me: exposing persisted metrics through a beta read API, using a stable response envelope, keeping the API in an extension module, and routing reads through table-scoped authz all make sense.

One thing I would still like to clarify before this merges is sequencing with the metrics SPI/schema work we discussed after the May 7 sync.

From the previous review thread, I think we already converged on a few points:

  • the current MetricsPersistence / PolarisMetricsManager layering is transitional, and #4397 is expected to move metrics persistence out of the old aggregated BasePersistence shape;
  • the current scan_metrics_report / commit_metrics_report split is also transitional, with the follow-up direction being a single metrics report model/table with a metric type discriminator;
  • listScanMetrics / listCommitMetrics are therefore likely interim API/SPI shapes, and may collapse or be rerouted when the schema/SPI consolidation happens.

I don't think this PR has to solve all of that before it can make progress. But I do think we should avoid accidentally standardizing the transitional shape just because this PR is ready first.

Could we make the sequencing explicit before merge? For example, either rebase on #4397 if that lands first, or link a concrete follow-up that tracks:

  1. moving metrics persistence to the standalone SPI shape,
  2. consolidating the metrics schema/model,
  3. deciding whether the per-type list methods remain public SPI surface or collapse behind a typed query API.

Comment thread spec/metrics-reports-service.yml
dimas-b
dimas-b previously approved these changes May 28, 2026
markmckeown pushed a commit to markmckeown/polaris that referenced this pull request Jul 22, 2026
  Added extension for publishing Polaris events to a
  Kafka topic.

  Application properties are:
    polaris.event-listener.types=kafka
    polaris.event-listener.kafka.topic
    polaris.event-listener.kafka.synchronous-mode
    polaris.event-listener.kafka.properties

  polaris.event-listener.kafka.properties is used to pass
  a set of properties through to the Kafka producer, for
  example bootstrap.server.

  The Kafka ProducerRecord key is the UUID from the PolarisEvent
  Metadata EventID and the value is a JSON key-value map encoded
  as a String.

  JSON message is generated in helper class to support future
  reuse.

  Tests use Kafka test container.

  Fixes apache#4115
markmckeown pushed a commit to markmckeown/polaris that referenced this pull request Jul 22, 2026
  Added extension for publishing Polaris events to a
  Kafka topic.

  Application properties are:
    polaris.event-listener.types=kafka
    polaris.event-listener.kafka.topic
    polaris.event-listener.kafka.synchronous-mode
    polaris.event-listener.kafka.properties

  polaris.event-listener.kafka.properties is used to pass
  a set of properties through to the Kafka producer, for
  example bootstrap.server.

  The Kafka ProducerRecord key is the UUID from the PolarisEvent
  Metadata EventID and the value is a JSON key/value map encoded
  as a String. TableIdentifiers are encoded as arrays of Strings.

  JSON message is generated in helper class to support future
  reuse.

  Tests use Kafka test container.

  Fixes apache#4115
markmckeown pushed a commit to markmckeown/polaris that referenced this pull request Jul 24, 2026
  Added extension for publishing Polaris events to a
  Kafka topic.

  Application properties are:
    polaris.event-listener.types=kafka
    polaris.event-listener.kafka.topic
    polaris.event-listener.kafka.synchronous-mode
    polaris.event-listener.kafka.properties

  polaris.event-listener.kafka.properties is used to pass
  a set of properties through to the Kafka producer, for
  example bootstrap.server.

  The Kafka ProducerRecord key is the UUID from the PolarisEvent
  Metadata EventID and the value is a JSON key/value map encoded
  as a String. TableIdentifiers are encoded as arrays of Strings.

  JSON message is generated in helper class to support future
  reuse.

  Tests use Kafka test container.

  Fixes apache#4115
markmckeown pushed a commit to markmckeown/polaris that referenced this pull request Jul 24, 2026
  Added extension for publishing Polaris events to a
  Kafka topic.

  Application properties are:
    polaris.event-listener.types=kafka
    polaris.event-listener.kafka.topic
    polaris.event-listener.kafka.synchronous-mode
    polaris.event-listener.kafka.properties

  polaris.event-listener.kafka.properties is used to pass
  a set of properties through to the Kafka producer, for
  example bootstrap.server.

  The Kafka ProducerRecord key is the UUID from the PolarisEvent
  Metadata EventID and the value is a JSON key/value map encoded
  as a String. TableIdentifiers are encoded as arrays of Strings.

  JSON message is generated in helper class to support future
  reuse.

  Tests use Kafka test container.

  Fixes apache#4115
markmckeown pushed a commit to markmckeown/polaris that referenced this pull request Jul 24, 2026
  Added extension for publishing Polaris events to a
  Kafka topic.

  Application properties are:
    polaris.event-listener.types=kafka
    polaris.event-listener.kafka.topic
    polaris.event-listener.kafka.synchronous-mode
    polaris.event-listener.kafka.properties

  polaris.event-listener.kafka.properties is used to pass
  a set of properties through to the Kafka producer, for
  example bootstrap.server.

  The Kafka ProducerRecord key is the UUID from the PolarisEvent
  Metadata EventID and the value is a JSON key/value map encoded
  as a String. TableIdentifiers are encoded as arrays of Strings.

  JSON message is generated in helper class to support future
  reuse.

  Tests use Kafka test container.

  Fixes apache#4115
@dimas-b dimas-b closed this in b1749c5 Jul 24, 2026
@github-project-automation github-project-automation Bot moved this from Ready to merge to Done in Basic Kanban Board Jul 24, 2026
@dimas-b

dimas-b commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Closed by mistake

@dimas-b dimas-b reopened this Jul 24, 2026
@github-project-automation github-project-automation Bot moved this from Done to PRs In Progress in Basic Kanban Board Jul 24, 2026
Comment on lines +46 to +67
Page<ScanMetricsRecord> listScanReports(
long catalogId,
long tableId,
@Nullable Long snapshotId,
@Nullable String principalName,
@Nullable Long timestampFrom,
@Nullable Long timestampTo,
@NonNull PageToken pageToken);

/**
* Lists persisted commit metrics reports for the given table, applying the supplied filters and
* returning at most one page of results.
*/
Page<CommitMetricsRecord> listCommitReports(
long catalogId,
long tableId,
@Nullable Long snapshotId,
@Nullable String principalName,
@Nullable Long timestampFrom,
@Nullable Long timestampTo,
@NonNull PageToken pageToken);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should scan and commit just being enveloped rather than individual SPI method?

could you please remind me the rationale of you choosing one over the other?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — the two-method shape here mirrors MetricsPersistence (the write-side SPI this interface is the read counterpart to), which also has two separate methods, writeScanReport/writeCommitReport, each taking its own concrete record type rather than an envelope.

The enveloping in IcebergMetricsReporter/MetricsReportEnvelope solves a different problem: it's the single ingestion entry point that receives whichever Iceberg report type shows up at runtime, so it needs one polymorphic signature with an explicit MetricType discriminator for callers to branch on. PersistingMetricsReporter (which implements IcebergMetricsReporter) unwraps that envelope and dispatches to the strongly-typed MetricsPersistence write methods.

MetricsQuerySpi doesn't have that "single arrival point" problem — callers already know whether they want scan or commit reports (it's the metricType query param), so there's no runtime type to discriminate. Keeping listScanReports/listCommitReports as separate, strongly-typed methods avoids introducing a union/envelope type on the read side purely to unwrap it again one line later, and keeps it symmetric with the write SPI it's paired with.

Happy to reconsider if you see a concrete benefit to enveloping here that I'm missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — collapsed listScanReports/listCommitReports into a single enveloped listReports(MetricType, ...) returning Page<? extends MetricsRecordIdentity>, matching the discriminated envelope the REST layer already uses (ListMetricsResponse oneOf). See 4a0b224.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with collapsing this to one envelope-based method. The remaining gap is that MetricType and the returned Page are still unrelated values, so a provider can pair COMMIT with scan records and the service has to recover the relationship through casts. Could listMetrics return a sealed QueryResult with typed ScanResult and CommitResult variants, with metricType derived from the variant? That keeps one entry point, makes an invalid envelope fail to compile, and lets the service use an exhaustive switch without casts. One check at the SPI seam can verify that the returned variant matches the requested runtime MetricType.

For example:

sealed interface QueryResult permits ScanResult, CommitResult {
  MetricType metricType();
}

record ScanResult(Page<ScanMetricsRecord> reports)
    implements QueryResult {

  @Override
  public MetricType metricType() {
    return MetricType.S.SCAN;
  }
}

record CommitResult(Page<CommitMetricsRecord> reports)
    implements QueryResult {

  @Override
  public MetricType metricType() {
    return MetricType.COMMIT;
  }
}

QueryResult listReports(
    MetricType metricType,
    long catalogId,
    List<Long> tableIds,
    Long snapshotId,
    Long timestampFrom,
    Long timestampTo,
    PageToken pageToken);

This way will safeguard such:

new CommitResult(scanPage); // compile error

service no longer needs to cast:

QueryResult result = provider.listReports(type, ...);

checkState(
    result.metricType() == type,
    "Provider returned %s for %s",
    result.metricType(),
    type);

return switch (result) {
  case ScanResult scan ->
      toScanResponse(scan.reports());

  case CommitResult commit ->
      toCommitResponse(commit.reports());
};

no op can simply be:

return switch (metricType) {
  case SCAN ->
      new ScanResult(Page.fromItems(List.of()));

  case COMMIT ->
      new CommitResult(Page.fromItems(List.of()));
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented as suggested in 975c01c (refactor(metrics): make MetricsQuerySpi.listReports return a typed sealed QueryResult):

  • listReports now returns a sealed QueryResult with ScanResult/CommitResult variants, each carrying a strongly-typed Page<ScanMetricsRecord>/Page<CommitMetricsRecord> and deriving metricType() from the variant.
  • MetricsReportsService checks result.metricType() == type and then switches exhaustively over ScanResult/CommitResult — no casts.
  • NoOpMetricsQuery returns new ScanResult(...)/new CommitResult(...) directly via a switch on the requested metricType, matching your proposed no-op shape.

Thanks for the detailed sketch — implemented essentially as written.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's intentional, not merge noise — this is the direct answer to your earlier ask on this thread: #4115 (comment) ("Could we provide an operator-consumable service-definition update and test LIST_TABLE_METRICS against that exact artifact?").

The problem it closes: authz_tests/dev_polaris.json's serviceDef is what RangerPolarisAuthorizerTest actually authorizes LIST_TABLE_METRICS against, but nothing shipped for operators to register with a real Ranger Admin — so the tested access types and the ones an operator could actually grant could silently diverge. This test asserts the new extensions/auth/ranger/src/main/resources/polaris-ranger-servicedef.json (the artifact I added for operators, referenced from the README) is byte-identical to that fixture's serviceDef, so any future edit to one without the other fails CI instead of drifting unnoticed.

Happy to fold it into RangerPolarisAuthorizerTest as one more @Test method instead of a separate class if you'd rather keep the file count down — let me know which you prefer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Apologies — my previous reply above landed on this thread by mistake; it was meant for the RangerServiceDefConsistencyTest thread. Posting the correct reply there now: #4115 (comment))

PageToken pt = PageToken.build(pageToken, pageSize, () -> true);
MetricsQuerySpi provider = queryProvider.get();

if ("commit".equalsIgnoreCase(metricType)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec says metricType is required, enum: [scan, commit], 400 otherwise. Code checks only equalsIgnoreCase("commit"), so any other value including a typo falls to the scan branch and succeeds. I see it as a bug

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in da04347 — metricType is now strictly validated (throws IllegalArgumentException / 400 for anything other than scan/commit), covered by a new test.

PolarisResolutionManifest manifest = resolveAndAuthorizeTableMetrics(catalogName, identifier);

CatalogEntity catalogEntity = manifest.getResolvedCatalogEntity();
long catalogId = catalogEntity != null ? catalogEntity.getId() : -1L;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

info: is -1L a safe fallback?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, not safe — replaced the -1L sentinel with a checkNotNull precondition in da04347, matching the fail-fast pattern used in IcebergCatalogHandler.


openapi: 3.0.3
info:
title: Apache Polaris Metrics Reports API

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're aiming to have a REST API for metrics queries, I'd suggest moving it to /spec. REST specs should be global for the entire Polaris project, so we shouldn't have separate REST specs for metric queries spread across different extensions.

minimum: 1
default: 100
description: Maximum number of results to return per page
- name: snapshotId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

type: integer
format: int64
description: Filter results to a specific snapshot ID
- name: principalName

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the use case of having a principalName filter?

default: localhost

paths:
/catalogs/{catalogName}/namespaces/{namespace}/tables/{table}:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need catalog/ns/table in the URL path? Hardcoding this hierarchy prevents querying metrics across multiple catalog objects in a single request.
A POST-based endpoint (e.g., accepting a QueryMetricsRequest payload) might be more flexible here, similar to the event query pattern in the IRC event spec (Apache Iceberg PR #12584).

Anand Kumar Sankaran added 5 commits August 18, 2026 19:18
Introduce the read/query surface for persisted Iceberg metrics, layered so
the query SPI is optional and the HTTP handler is a thin adaptor:

- Add the OpenAPI spec spec/metrics-reports-service.yml and the generated
  api/metrics-reports-service module (api under org.apache.polaris.service
  .metrics.api, models under org.apache.polaris.core.metrics.api.model).
- Add MetricsQuerySpi in a new extensions/metrics-reports/spi module
  (listScanReports/listCommitReports over core record types + PageToken).
- Add the REST handler MetricsReportsService in runtime/service as a thin
  HTTP-to-SPI adaptor: resolves catalog/namespace/table names to ids,
  authorizes, and delegates to MetricsQuerySpi; returns HTTP 501 when no
  durable query backend is installed.
- Add read-path authorization in polaris-core: TABLE_READ_METRICS privilege
  and LIST_TABLE_METRICS operation, wired through PolarisAuthorizerImpl and
  RbacOperationSemantics; mirror the mapping in the Ranger extension and its
  authz test fixtures.
- Docs + changelog for the beta API and the new privilege.

The JDBC implementation of MetricsQuerySpi follows in PR2.
…query default (PR1)

Address the community review of PR1 to match the agreed intent:

- Relocate the metrics query REST API to extensions: the OpenAPI spec and the
  generated module now live under extensions/metrics-reports/api (coordinate
  polaris-extensions-metrics-reports-api), signalling an optional,
  extension-scoped feature. runtime/service still compile-depends on it for the
  thin handler.
- Add a no-op default MetricsQuerySpi (NoOpMetricsQuery) in
  extensions/metrics-reports/base, annotated @io.quarkus.arc.DefaultBean so it
  is active only when no durable backend contributes a MetricsQuerySpi. The read
  path now always resolves a provider and returns an empty page instead of HTTP
  501 when no backend is installed.
- Simplify MetricsReportsService accordingly (drop the 501 branch) and update
  its unit tests to assert the empty-page behavior.
…R1 review)

- listTableMetrics silently treated any non-"commit" metricType value as
  "scan" (e.g. a typo), even though the spec requires metricType to be one
  of [scan, commit] and reject anything else with 400. Reject values that
  don't match "scan" or "commit".
- Replace the -1L catalogId sentinel (used when the resolved catalog entity
  was unexpectedly null) with a checkNotNull precondition, matching the
  fail-fast pattern already used in IcebergCatalogHandler.
…ports method (PR1 review)

MetricsQuerySpi previously exposed separate listScanReports/listCommitReports
methods even though the REST layer already models the response as a single
enveloped, discriminated type. Replace both with a single listReports(MetricType, ...)
method returning Page<? extends MetricsRecordIdentity>, matching the existing
oneOf/discriminator envelope pattern used by the API spec.
… query (PR1 review)

Address review feedback on apache#4115:
- Move metrics-reports-service.yml into the shared /spec directory instead of
  keeping a REST spec scoped to one extension.
- Replace the per-table GET endpoint with a POST /catalogs/{catalogName}/metrics/query
  endpoint accepting a QueryMetricsRequest body, so a single request can span
  multiple tables in a catalog (mirrors the Iceberg events query pattern).
  Each report's object now carries a table reference to disambiguate results.
- Drop the principalName filter parameter; no concrete use case was given for it.

@flyingImer flyingImer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API direction looks right, and the latest POST multi-table shape addresses the API-shape feedback. I don't think this is merge-ready yet. Two contract blockers and two narrower AuthZ/scope questions are inline. Please also rebase onto main and refresh the stacked-PR description before the next round.

public class NoOpMetricsQuery implements MetricsQuerySpi {

@Override
public Page<? extends MetricsRecordIdentity> listReports(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listMetrics? not sure if reports term makes the most sense here

Comment on lines +462 to 465
SUPER_PRIVILEGES.putAll(
TABLE_READ_METRICS,
List.of(CATALOG_MANAGE_CONTENT, TABLE_FULL_METADATA, TABLE_READ_DATA, TABLE_READ_METRICS));
SUPER_PRIVILEGES.putAll(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TABLE_READ_DATA currently subsumes TABLE_READ_METRICS here. That lets every data reader query principalName, requestId, and trace/span IDs for all stored reports on the table without an explicit metrics grant. Is that intended? Since this PR adds a dedicated privilege for operational metadata, my bias is to keep it independently grantable unless we explicitly want that disclosure in the TABLE_READ_DATA contract.

Comment on lines +263 to +266
TABLE_READ_METRICS(
103,
PolarisEntityType.TABLE_LIKE,
List.of(PolarisEntitySubType.ICEBERG_TABLE, PolarisEntitySubType.GENERIC_TABLE),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I missed this earlier: TABLE_READ_METRICS is grantable on generic tables here, but the API and current ingestion path are Iceberg-only. A generic-table request can authorize successfully and then return no reports, which makes the contract look broader than the implementation. Could we restrict this privilege and the service lookup to ICEBERG_TABLE until generic-table metrics exist?

…READ_DATA implication (PR1 review)

TABLE_READ_METRICS gates operational metadata (principal name, request id,
otel trace/span ids) on metrics reports, separate from table data. It was
listed as implied by TABLE_READ_DATA, which let any data reader see that
metadata without an explicit metrics grant; drop that implication so it's
only implied by TABLE_FULL_METADATA (and CATALOG_MANAGE_CONTENT).

The privilege was also grantable on GENERIC_TABLE, but metrics ingestion and
the query API only cover Iceberg tables today, so a generic-table request
could authorize successfully and then return no reports, making the
contract look broader than the implementation. Restrict the privilege and
the service's table resolution to ICEBERG_TABLE until generic-table metrics
exist; a generic-table request now fails with 404 instead of a
misleadingly-successful empty result.
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";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LIST_TABLE_METRICS now requests table-metrics-read, but Polaris pins Ranger 2.9.0 and neither its Polaris service definition nor Ranger master defines that access type. Ranger validates policy access types against the service definition, while this PR adds it only to embedded test fixtures and does not add it to the table resource’s accessTypeRestrictions. Operators therefore cannot grant the new permission through the shipped definition. Could we provide an operator-consumable service-definition update and test LIST_TABLE_METRICS against that exact artifact?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 32d7bf7351d (fix(metrics): ship operator-consumable Ranger service def, close data-read/metrics-read gap):

  • Extracted the serviceDef into extensions/auth/ranger/src/main/resources/polaris-ranger-servicedef.json, the artifact operators register with Ranger Admin (README updated with the registration steps).
  • Added table-metrics-read to the table resource's accessTypeRestrictions, which was indeed missing.
  • Added RangerServiceDefConsistencyTest, which fails the build if that shipped artifact ever drifts from the serviceDef in authz_tests/dev_polaris.json — the same fixture RangerPolarisAuthorizerTest exercises for LIST_TABLE_METRICS and the rest of the authz suite. So the operator-facing service definition and the tested one are now provably the same artifact.

Comment on lines +46 to +67
Page<ScanMetricsRecord> listScanReports(
long catalogId,
long tableId,
@Nullable Long snapshotId,
@Nullable String principalName,
@Nullable Long timestampFrom,
@Nullable Long timestampTo,
@NonNull PageToken pageToken);

/**
* Lists persisted commit metrics reports for the given table, applying the supplied filters and
* returning at most one page of results.
*/
Page<CommitMetricsRecord> listCommitReports(
long catalogId,
long tableId,
@Nullable Long snapshotId,
@Nullable String principalName,
@Nullable Long timestampFrom,
@Nullable Long timestampTo,
@NonNull PageToken pageToken);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with collapsing this to one envelope-based method. The remaining gap is that MetricType and the returned Page are still unrelated values, so a provider can pair COMMIT with scan records and the service has to recover the relationship through casts. Could listMetrics return a sealed QueryResult with typed ScanResult and CommitResult variants, with metricType derived from the variant? That keeps one entry point, makes an invalid envelope fail to compile, and lets the service use an exhaustive switch without casts. One check at the SPI seam can verify that the returned variant matches the requested runtime MetricType.

For example:

sealed interface QueryResult permits ScanResult, CommitResult {
  MetricType metricType();
}

record ScanResult(Page<ScanMetricsRecord> reports)
    implements QueryResult {

  @Override
  public MetricType metricType() {
    return MetricType.S.SCAN;
  }
}

record CommitResult(Page<CommitMetricsRecord> reports)
    implements QueryResult {

  @Override
  public MetricType metricType() {
    return MetricType.COMMIT;
  }
}

QueryResult listReports(
    MetricType metricType,
    long catalogId,
    List<Long> tableIds,
    Long snapshotId,
    Long timestampFrom,
    Long timestampTo,
    PageToken pageToken);

This way will safeguard such:

new CommitResult(scanPage); // compile error

service no longer needs to cast:

QueryResult result = provider.listReports(type, ...);

checkState(
    result.metricType() == type,
    "Provider returned %s for %s",
    result.metricType(),
    type);

return switch (result) {
  case ScanResult scan ->
      toScanResponse(scan.reports());

  case CommitResult commit ->
      toCommitResponse(commit.reports());
};

no op can simply be:

return switch (metricType) {
  case SCAN ->
      new ScanResult(Page.fromItems(List.of()));

  case COMMIT ->
      new CommitResult(Page.fromItems(List.of()));
};

{ "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" ] },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, Ranger and native RBAC give different results for the same conceptual grant: a user with only table-data-read can access table metrics through Ranger, but not through native RBAC. In Ranger, this happens because table-data-read implies table-metrics-read. Could we remove that implication from both table-data-read and table-data-write and add a negative test showing that data access alone does not authorize LIST_TABLE_METRICS?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 32d7bf7351d, same commit as the sibling thread above:

  • Dropped table-metrics-read from table-data-read's and table-data-write's impliedGrants in both authz_tests/dev_polaris.json and the intTest fixture, matching the native RBAC side (table-metrics-read is now only implied by table-metadata-full/catalog-content-manage there too).
  • Added a policy item granting only table-data-read/table-data-write to a new test principal (dataonly1) in both fixtures.
  • Added a negative LIST_TABLE_METRICS test case for dataonly1 (expected isAllowed: false) alongside a positive case for admin1, in tests_authz_table.json.

@dimas-b

dimas-b commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@obelix74 : Do you have time to refresh this PR?

@obelix74

obelix74 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

@obelix74 : Do you have time to refresh this PR?

Working on it.

Anand Kumar Sankaran and others added 3 commits September 8, 2026 16:11
…-read/metrics-read gap (PR1 review)

Ranger validates policy access types against the registered service
definition. dev_polaris.json under test resources was the only place
table-metrics-read existed, so operators had no artifact to register with
Ranger Admin, and the table resource's own accessTypeRestrictions never
listed table-metrics-read in the first place.

Extract the serviceDef into extensions/auth/ranger/src/main/resources/
polaris-ranger-servicedef.json as the artifact operators register, add
table-metrics-read to the table resource's accessTypeRestrictions, and add
RangerServiceDefConsistencyTest so the shipped artifact and the fixture
RangerPolarisAuthorizerTest runs against can never drift apart again.

Also drop table-metrics-read from table-data-read/table-data-write's
impliedGrants in both the unit-test and intTest fixtures, matching the
native RBAC side (table-metrics-read is only implied by table-metadata-full
and catalog-content-manage there); a data reader/writer should not
automatically see metrics report metadata (principal name, request id,
otel ids) via Ranger either. Add a negative LIST_TABLE_METRICS test case
for a principal with only table-data-read/table-data-write access.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…aled QueryResult (PR1 review)

MetricType and the returned Page were unrelated values, so a provider
could pair COMMIT with scan records and the service had to recover the
relationship through unchecked casts. Introduce a sealed QueryResult with
ScanResult/CommitResult variants that each carry a strongly-typed Page and
derive their MetricType from the variant, so an invalid pairing fails to
compile instead of failing at runtime.

MetricsReportsService checks that the returned variant matches the
requested MetricType and then switches exhaustively over ScanResult/
CommitResult with no casts. NoOpMetricsQuery now returns the matching
variant directly instead of an untyped empty Page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	bom/build.gradle.kts
#	gradle/projects.main.properties
"serviceDef": {
"name": "polaris",
"displayName": "Polaris (draft)",
"displayName": "Apache Polaris",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change related to metrics?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, not related to metrics at all — that was collateral from making my new RangerServiceDefConsistencyTest's byte-equality check look tidier. Reverted in 5b55c34 (both here and in the intTest fixture / the new polaris-ranger-servicedef.json), back to Polaris (draft).

* keeping the two identical is what makes those tests representative of the artifact operators
* register with Ranger Admin.
*/
public class RangerServiceDefConsistencyTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a spurious change in this PR 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's intentional, not merge noise — this is the direct answer to your earlier ask on the sibling thread: #4115 (comment) ("Could we provide an operator-consumable service-definition update and test LIST_TABLE_METRICS against that exact artifact?").

The problem it closes: authz_tests/dev_polaris.json's serviceDef is what RangerPolarisAuthorizerTest actually authorizes LIST_TABLE_METRICS against, but nothing shipped for operators to register with a real Ranger Admin — so the tested access types and the ones an operator could actually grant could silently diverge. This test asserts the new extensions/auth/ranger/src/main/resources/polaris-ranger-servicedef.json (the artifact I added for operators, referenced from the README) is byte-identical to that fixture's serviceDef, so any future edit to one without the other fails CI instead of drifting unnoticed.

Happy to fold it into RangerPolarisAuthorizerTest as one more @Test method instead of a separate class if you'd rather keep the file count down — let me know which you prefer.

…splayName tweak (post-merge)

Merging upstream/main pulled in apache#5194 (Update CatalogHandler and
PolarisAdminServier to authorize using AuthorizationIntent), which removed
PolarisAuthorizer.authorizeOrThrow entirely. Migrate
MetricsReportsService.resolveAndAuthorizeTableMetrics to build one
AuthorizationRequest of SingleTargetAuthorizationIntent(LIST_TABLE_METRICS,
...) per table, call resolveAuthorizationInputs once, do the existing
not-found checks against the manifest, then authorize(...).throwIfDenied()
once for the whole batch (AND-combined, short-circuits on first deny) --
mirroring CatalogHandler.authorizeBatchTableLikeOperationOrThrow. Update
MetricsReportsServiceTest's mocks to the authorize()/AuthorizationDecision
style used elsewhere post-migration.

Also revert the dev_polaris.json/polaris-ranger-servicedef.json
"Polaris (draft)" -> "Apache Polaris" displayName tweak from the prior
commit: unrelated to metrics, flagged in review
(apache#4115 (comment)).

spotlessApply also reflowed the TABLE_READ_METRICS javadoc comment in
PolarisPrivilege.java by a couple of characters (newer google-java-format
line-wrap width); included since it touches PR1's own new javadoc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants