Skip to content

Core: Add table-name filter for MetricsReporter - #16574

Open
moomindani wants to merge 6 commits into
apache:mainfrom
moomindani:moomindani/metrics-reporter-table-filter
Open

Core: Add table-name filter for MetricsReporter#16574
moomindani wants to merge 6 commits into
apache:mainfrom
moomindani:moomindani/metrics-reporter-table-filter

Conversation

@moomindani

@moomindani moomindani commented May 27, 2026

Copy link
Copy Markdown
Contributor

Closes #16573.

Adds an optional filtering layer above any MetricsReporter implementation that drops ScanReport and CommitReport instances whose tableName() does not pass the configured include / exclude regex. The filter applies uniformly to LoggingMetricsReporter, RESTMetricsReporter, and custom user-supplied reporters. The proposal surfaced in the dev@ DISCUSS thread for #16250 (per-table cardinality of the OTel reporter) and is intentionally scoped as cross-reporter, not OTel-specific.

Design

CatalogUtil.loadMetricsReporter wraps the resolved reporter in a FilteringMetricsReporter when either of the new properties is set. When neither is set, the resolved reporter is returned unchanged — no wrapper instantiated, no runtime overhead on the default path. MetricsReport subtypes that do not expose a table name (anything other than ScanReport / CommitReport) are forwarded without filtering.

Configuration

Two new catalog properties:

metrics-reporter.table-name.include=prod_db\..*
metrics-reporter.table-name.exclude=.*\.tmp_.*

Values are Java regex patterns matched against the table name. When both are set, exclude wins over include (an explicit deny overrides an include). Empty values are treated as not set to avoid accidentally silencing all metrics on misconfiguration. Invalid regex values fail fast at catalog initialization with a clear error pointing at the offending property.

Behavior:

  • include only: forward reports whose table name matches; drop others.
  • exclude only: drop reports whose table name matches; forward others.
  • Both set: drop if exclude matches; otherwise forward only if include matches.
  • Neither set: forward everything (current behavior).

This mirrors the existing route-regex pattern used in iceberg-kafka-connect (IcebergSinkConfig), where a user-supplied regex from configuration is compiled via Pattern.compile() and matched against incoming data. Same trust model: catalog property = admin-controlled.

Design choice — where the filter wrap lives in REST catalog flow

The REST catalog adds an additional RESTMetricsReporter per-table inside RESTSessionCatalog.metricsReporter(...), separate from the user's metrics-reporter-impl. To make the table-name filter apply uniformly to both reporters, three shapes were considered:

A. Wrap inside RESTSessionCatalog.metricsReporter(...) (chosen). The RESTMetricsReporter is wrapped with FilteringMetricsReporter using the catalog properties stored at init, then combined with the user reporter. Smallest local change. Keeps MetricsReporters and FilteringMetricsReporter mutually unaware. The additional wrap lives next to the existing combine(reporter, restMetricsReporter) line, which is itself REST-specific wiring.

B. Make MetricsReporters.combine() aware of FilteringMetricsReporter. Detect when one input is a filtering wrapper and "lift" it to wrap the composite. Avoids touching REST catalog code; would apply uniformly to any future combine site. But couples a generic utility to a specific reporter implementation and changes combine() semantics for all callers.

C. Wrap at the scan/commit framework layer (TableScanContext / BaseTable.combineMetricsReporter). Apply the filter wrap on the final composed reporter at the point it's used. Catalog-agnostic. But touches scan/commit framework code for what is logically a catalog-level concern, and requires plumbing filter properties down to that layer.

Option A was preferred because the combine(reporter, restMetricsReporter) line in RESTSessionCatalog is already REST-specific wiring (no other catalog does this combine), so the additional wrap lives in the same conceptual location rather than introducing knowledge of filtering elsewhere. Happy to revisit if reviewers prefer one of the other shapes.

Disclosure

Per the project's AI-assisted contribution guidelines, I used Claude Code to help draft this work. I reviewed every change by hand and ran the full test/lint loop locally before opening this PR. The design and motivation discussion is in #16573.

cc @ebyhr @jbonofre — happy to address any feedback.

@gaborkaszab gaborkaszab 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.

Hey @moomindani ,
I'm not sure this works with REST catalog. It creates a reporter using CatalogUtil.loadMetricsReporter where you wrap it with FilteringMetricsReporter, but then for each table it combines this with a RESTMetricsReporter that is not filtering. Is this the intended design? Would be nice to see some REST catalog tests too. Maybe some general catalog test where we set up the MetricsReporter via configs, and then it gets wrapped with the filtering one?
Before going deeper into the code, I'd wait to see if there is community buy in for this change, TBH.

@moomindani

Copy link
Copy Markdown
Contributor Author

Hi @gaborkaszab,

Thanks for the catch — that was unintended. The filter is meant to apply uniformly, and the asymmetry between the CatalogUtil-wrapped user reporter and the catalog-injected RESTMetricsReporter was a real bug.

Pushed a follow-up commit:

  • Fix: RESTSessionCatalog.metricsReporter(...) now wraps the RESTMetricsReporter with the same FilteringMetricsReporter before combining with the user reporter, so both sides of the combined reporter honor the filter. Went with the local wrap in RESTSessionCatalog over a couple of alternative shapes (combine-aware filter, scan/commit-framework wrap) — added a short rationale section to the PR description.

  • REST catalog test (TestRESTCatalog.metricsFilterAppliesToRestMetricsReporter): exercises RESTSessionCatalog.metricsReporter(...) with filter properties and a mock RESTClient; verifies that one filtered + one unfiltered report produce exactly one post() (the filtered one short-circuits before reaching the client).

  • Configs-driven catalog test (TestFilteringMetricsReporter.loadMetricsReporterFiltersThroughUserConfiguredReporter): sets up metrics-reporter-impl + filter properties through CatalogUtil.loadMetricsReporter with a static-singleton capturing reporter, demonstrating the wrap actually applies at the catalog wiring level — that's the "general catalog test" you asked about.

I also locally combined this PR with the proposed OTel MetricsReporter from #16250 and ran an integration test against InMemoryMetricReader to verify the composition. Excluded tables don't reach the OTel pipeline; included tables emit normally. The two layers (table-name filter + OTel attribute allowlist) compose cleanly via the MetricsReporter interface without special wiring. Not committed here since #16250 hasn't landed.

Fully agree on the community buy-in point — the cardinality concern that motivated this proposal came up in the dev@ DISCUSS for #16250 (the OTel reporter, which is where Grant flagged it), not on a thread specific to this PR. Happy to wait for broader signal. Just wanted the design question and the tests to be in a reviewable state for when reviewers come back.

Thanks again for the careful read.

@github-actions

Copy link
Copy Markdown

This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that’s incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions.

@github-actions github-actions Bot added the stale label Jun 27, 2026
@moomindani
moomindani force-pushed the moomindani/metrics-reporter-table-filter branch from 425acd7 to 132b48e Compare June 28, 2026 23:59
@moomindani

Copy link
Copy Markdown
Contributor Author

Not stale — this PR is actively maintained and waiting for reviewer feedback.

Rebased onto latest main to resolve the merge conflicts in RESTSessionCatalog / TestRESTCatalog (they overlapped with the recent RESTMetricsReporter async-executor change — the table-name filter now wraps the executor-backed RESTMetricsReporter). Core compiles and the metrics-filter / REST-catalog tests pass.

The design questions raised in the last review round have been addressed (REST-reporter symmetry fix + general catalog test). Happy to incorporate any further feedback whenever a reviewer has a chance to take a look.

@github-actions github-actions Bot removed the stale label Jun 29, 2026
@moomindani

Copy link
Copy Markdown
Contributor Author

Still active and waiting for review — not stale.

Status since the last update:

  • All 56 CI checks are green, and the branch is mergeable with no unresolved review threads.
  • @gaborkaszab's REST catalog finding from May 27 is addressed: RESTSessionCatalog.metricsReporter(...) now wraps the per-table RESTMetricsReporter in FilteringMetricsReporter as well, so the filter applies uniformly to the user-supplied reporter and the catalog-injected one. The PR description documents the three wiring options considered (A/B/C) and why A was chosen — happy to switch if a different shape is preferred.
  • No behavior change on the default path: when neither metrics-reporter.table-name.include nor .exclude is set, the resolved reporter is returned unwrapped.

@gaborkaszab would you mind taking another look at the REST fix, since you raised the original issue? @ebyhr @jbonofre — a review from any of you would also be welcome. This is a small, self-contained change on top of #16573.

@gaborkaszab

Copy link
Copy Markdown
Contributor

Hi @moomindani ,

Would be nice to hear the opinion of experts on this, but here is why I'm somewhat hesitant on the approach:

  1. The PR defines table name regexes that we include / exclude from metrics reporting. This assumes that we can decide whether or not to report metrics for a table by applying some predicate on its name. Are we sure that name is enough to judge this? What if we introduce a regex now, but then it excludes some future tables that we don't want to exclude?
    I don't see how exactly this functionality would be used in prod. Maybe having a single list of table names that we want to exclude and expect exact match instead of regex?
  2. These regexes are configured via catalog properties. When we want to change them (for instance we have a new table that matches some of the regexes we don't want) then this requires restarting the client-side catalog to pick up new values, right? There might be engines that start a new client-side catalog connector for each query, for them this isn't an issue. I know of engines that launch the client-side catalog connector when the engine launches and keeps this single instance open. For them this design might be rigid and inconvenient.

Before moving forward I'd suggest having a wider community opinion on this.

@moomindani

Copy link
Copy Markdown
Contributor Author

Thanks @gaborkaszab — both concerns are fair, and the second one is a real limitation of the current shape rather than something I'd argue away. Below are the options I see for each, so the discussion has something concrete to react to. I'll take this to dev@ as you suggest.

Concern 1 — Is the table name the right predicate? Regex or exact match?

Option Expresses #16573's use cases Surprise-capture risk Config burden
A Table-name regex (current PR) Yes — prod.*, exclude tmp.* Yes — a pattern written today silently captures tables created tomorrow Low (one line)
B Exact-match name list No — requires enumerating every table None High — every new table is a config change
C Namespace-level filter Mostly — most cases are "this whole database" Low — namespace is structural, not a naming convention Low
D Exact list + optional regex Yes, if you opt in Yes, but as an explicit choice Low–High (two mechanisms)

Detail on the tradeoffs:

  • A matches how the same problem is already solved in this repo — iceberg-kafka-connect compiles a user-supplied route-regex from config (IcebergSinkConfig). Its failure mode is the one you name, and it's invisible: metrics just stop appearing for a table nobody thought about.
  • B is the safest and most auditable, but it doesn't express the motivating cases. "Report only production tables" across thousands of tables means maintaining thousands of names — and, given Concern 2, a catalog restart per addition. It turns a one-line policy into an operational chore.
  • C is worth considering because most cases in Core: Add table-level filtering for MetricsReporter implementations #16573 are namespace-shaped rather than table-shaped. Caveat: ScanReport/CommitReport expose only a flat tableName() string today, so this means parsing the name back into an identifier. It also can't express intra-namespace exclusions (a noisy scratch table inside an otherwise interesting database).

My read: I lean A or C, without strong conviction. This is the part I'd most like broader input on, since it's a question about how operators actually organize tables, not about the implementation.

Concern 2 — Catalog properties require a restart

Confirmed, and to be precise about the scope: this is not specific to this PR. metrics-reporter-impl and every other catalog property share the same lifecycle. The patterns compile once at catalog initialization and live as long as the catalog instance, so an engine holding a single long-lived catalog connector can only change this by restarting.

Option Runtime-adjustable Scope of change
A Accept static lifecycle (current PR) No None — consistent with all existing properties
B Periodic / signal-based reload Yes Large — Iceberg has no mechanism for re-reading catalog properties
C Leave dynamic control to the reporter impl Yes, user-implemented None — MetricsReporter.initialize(Map) already exists

Detail on the tradeoffs:

  • A is the rigidity you point at. For long-lived-catalog engines, tuning what gets reported becomes a restart-level operation — which in practice means nobody tunes it.
  • B would require inventing a config-reload facility (reporter-local refresh loop, or something broader). That's a much bigger design conversation, and it shouldn't be settled as a side effect of table filtering.
  • C narrows this PR's value honestly: users needing runtime control implement it in their own reporter against whatever config source they already operate, and this PR covers the common static case without anyone writing code. Worth noting that for the OTel reporter specifically, operators who run a Collector already have a runtime-adjustable path — the Collector's filterprocessor applies attribute-value predicates centrally, without restarting anything on the Iceberg side. That doesn't help LoggingMetricsReporter or RESTMetricsReporter users, but it does mean the static limitation bites unevenly depending on the reporter.

My read: B is out of scope here, so the real choice is A vs. C — which is ultimately a question of how much this feature is worth if it can only be set at startup.

Stepping back — could this be left to reporter implementations and backend tooling?

Worth naming explicitly as a "don't do this" option: ship no built-in filter, and let each reporter or the backend handle it. I looked into how OpenTelemetry expects this to be solved, since that was the original context in #16250, and the answer is narrower than I assumed:

  • SDK Views cannot do this. Per the metrics SDK spec, instrument selection predicates are name / type / unit / meter_name / meter_version / meter_schema_url — attribute values are not selection criteria. attribute_keys is an allow/exclude list of attribute keys, not values. So "drop the streams where iceberg.table.name matches tmp.*" is not expressible as a View. This is the same distinction as the iceberg.otel.metrics.attributes allowlist in Core: Add OpenTelemetry MetricsReporter #16250: that controls which attributes a metric carries, not whether the metric is emitted at all.
  • Value-based filtering lives in two places in the OTel model: the Collector (filterprocessor), or at instrumentation time before recording. This PR is the second one, so filtering in the reporter layer isn't a deviation from how OTel expects this to work.
  • The Collector path only covers OTel users. LoggingMetricsReporter and RESTMetricsReporter have no equivalent, and a per-reporter solution means each one re-solves it slightly differently — which was the original argument in Core: Add table-level filtering for MetricsReporter implementations #16573 for putting it above the reporter layer.

So "leave it to backend tooling" is a real option, but it's specifically an option to support OTel-with-a-Collector users and not the others. That tradeoff seems worth being explicit about rather than assuming the backend can always absorb it.

Next step

I'll start a [DISCUSS] thread on dev@ with this summary and link it here. Happy to hold the PR until there's a direction, and to rework it toward whichever shape the community prefers — including dropping it if the consensus is that this doesn't belong in the framework.

@moomindani

Copy link
Copy Markdown
Contributor Author

Started the dev@ [DISCUSS] thread as promised: https://lists.apache.org/thread/dmf60fs64swkcy8cpdgkxn2nldx836xo

It covers both of your concerns with the alternatives laid out, plus the "should this be in the framework at all" question. Holding this PR until there's a direction from the list.

Add an optional filtering layer above any MetricsReporter implementation
that drops ScanReports and CommitReports whose tableName() does not pass
the configured include / exclude regex. Two new catalog properties
control the filter: metrics-reporter.table-name.include and
metrics-reporter.table-name.exclude. Both are Java regex patterns
matched against the table name; when both are set, exclude wins over
include.

When neither property is set, CatalogUtil.loadMetricsReporter returns
the underlying reporter unchanged, so the default code path incurs no
runtime overhead. Empty values are treated as not set to avoid
accidentally silencing all metrics on misconfiguration. Invalid regex
values fail fast at catalog initialization with a clear error pointing
at the offending property.

The filter applies uniformly across all reporter implementations
(LoggingMetricsReporter, RESTMetricsReporter, and custom user-supplied
ones). Reports whose subtype does not expose a table name are forwarded
without filtering.

Closes apache#16573
The table-name filter introduced earlier in this PR is applied via
CatalogUtil.loadMetricsReporter to the user-configured reporter, but
RESTSessionCatalog injects an additional RESTMetricsReporter per table
inside metricsReporter(...), which previously bypassed the filter. Wrap
that RESTMetricsReporter with the same FilteringMetricsReporter (using
the catalog properties stored at init) before combining with the user
reporter, so both sides of the combined reporter honor the configured
table-name filter.

Add two tests:

- TestRESTCatalog.metricsFilterAppliesToRestMetricsReporter exercises
  RESTSessionCatalog.metricsReporter(...) with filter properties and a
  mock RESTClient, verifying that one filtered + one unfiltered scan
  report produce exactly one post() and that the filtered report
  short-circuits before reaching the client.

- TestFilteringMetricsReporter.loadMetricsReporterFiltersThroughUserConfiguredReporter
  goes through CatalogUtil.loadMetricsReporter with a static-singleton
  capturing reporter, demonstrating that the wrap applies at the
  catalog wiring level when metrics-reporter-impl plus the filter
  properties are configured together.
The rebase onto main picked up the change that makes RESTMetricsReporter
export asynchronously via a single-threaded executor. The filter test
verified post() synchronously, so it raced the background export and
failed intermittently in CI (zero interactions). Use timeout()-based
verification, matching the existing async CommitReport tests.
…lter

The table-name include / exclude properties took a single regex, so covering
several databases meant hand-writing an alternation. Accept a comma-separated
list instead, matching how the same problem is configured elsewhere:
Debezium's table.include.list, DataHub's table_pattern allow/deny, and
OpenMetadata's tableFilterPattern all take a list of expressions.

Patterns were already matched against the whole name via Matcher.matches(),
which is the same choice Debezium documents as an "anchored regular
expression". That property is worth stating explicitly, since an unanchored
prod.* would otherwise capture production.orders and prod_sandbox.orders --
a silent over-match that is hard to notice. Documented it and added a test
that pins the behavior.
Filtering only by table name relies on a naming convention: a pattern written
today silently captures tables created tomorrow, and the failure mode is
invisible because metrics simply stop appearing for a table nobody thought
about. A namespace is part of a table's identity instead, so a table added to
an included namespace later is picked up automatically and a table outside it
cannot match by accident.

Add metrics-reporter.namespace.include / .exclude alongside the existing
table-name filters. Both levels are applied independently, so a namespace can
be selected while individual noisy tables inside it are still excluded by
name -- the coarse-plus-fine combination that database.include.list plus
table.include.list provides in Debezium, and schema_pattern plus
table_pattern in DataHub.

The namespace is derived from the reported table name by removing the catalog
name, which the catalog supplies when it loads the reporter. Knowing the
catalog name is what makes this unambiguous: the reported name is built by
CatalogUtil#fullTableName, which switches its separator for URI-like catalog
names and offers no way to tell a dotted catalog name from the namespace that
follows it. Tests cover both shapes, along with multi-level and empty
namespaces. Configuring a namespace filter where no catalog name is available
fails at initialization rather than silently dropping every report.
@moomindani
moomindani force-pushed the moomindani/metrics-reporter-table-filter branch from c706ebc to 5da80bf Compare August 7, 2026 05:10
@moomindani

Copy link
Copy Markdown
Contributor Author

@gaborkaszab I've reworked the PR to address both of your concerns. Rather than argue for the original shape, I went looking for how other projects solve the same problem, since the dev@ thread [1] hasn't drawn input — that seems to be the norm for the list rather than a signal about this proposal, as eight of the twelve [DISCUSS] threads started last month have no replies.

Three projects that filter tables for exactly this reason converge on the same design:

Coarse level Fine level Value format
Debezium database.include.list table.include.list / .exclude.list comma-separated regexes, matched against the fully-qualified name
DataHub schema_pattern table_pattern (allow/deny) list of regexes; deny wins over allow
OpenMetadata schemaFilterPattern tableFilterPattern includes/excludes lists

Two things stood out, and both are now in the PR.

1. Both levels, not one instead of the other. All three offer a coarse level and a per-table level. That matches my own operational experience: selecting whole databases is the common case, but production configuration eventually needs to exclude a specific table inside an otherwise interesting database, and a namespace-only filter cannot express that. So the PR keeps table-name filtering and adds metrics-reporter.namespace.include / .exclude alongside it. The two levels apply independently, so you can select prod and analytics while still dropping bench_* tables anywhere:

metrics-reporter.namespace.include=prod,analytics
metrics-reporter.table-name.exclude=.*\.bench_.*

This directly addresses your point about name-based prediction being fragile. A namespace is part of a table's identity rather than a naming convention, so a table added to an included namespace later is picked up automatically, and a table outside it cannot match by accident. Namespace filtering is the safer default; the table-name level remains for what a namespace cannot express.

2. A list of patterns, not a single one. All three accept a list. The PR now does too, so covering several databases no longer means hand-writing an alternation.

On the surprise-capture risk specifically, it's worth stating something that was already true but undocumented: patterns are matched against the entire name via Matcher.matches(), never a substring. Debezium documents the same choice as an "anchored regular expression" — "the specified expression is matched against the entire name string of the table; it does not match substrings". Concretely, prod\..* matches prod.db.table but not production.db.table or prod_sandbox.db.table. An unanchored implementation would silently capture all three, which is the failure mode you were pointing at. It's now documented and pinned by a test.

Deriving the namespace does not touch ScanReport/CommitReport, deliberately — those are defined in the REST OpenAPI spec, so changing them is a much larger conversation than this PR. Instead the catalog passes its own name when it loads the reporter, and the namespace is derived from the reported table name by stripping that prefix. Knowing the catalog name is what makes this unambiguous: CatalogUtil#fullTableName switches its separator for URI-like catalog names and gives no way to tell a dotted catalog name from the namespace that follows. Tests cover both shapes plus multi-level and empty namespaces.

On your second concern (static lifecycle). I haven't changed this, and I still think the honest framing is the one from my earlier comment: adding config reload would mean inventing a mechanism Iceberg doesn't have, which shouldn't be settled as a side effect of this feature. What did change is that namespace filtering reduces how often you'd need to touch the config at all — adding a table to an included namespace requires no config change, and therefore no restart. That doesn't eliminate the limitation you identified, but it makes it bite less often in the case that motivated this.

Two commits, split for review: one for the pattern list, one for the namespace filter. All 39 CI checks are green. Happy to keep iterating on the shape.

[1] https://lists.apache.org/thread/dmf60fs64swkcy8cpdgkxn2nldx836xo

@moomindani

Copy link
Copy Markdown
Contributor Author

One more data point for why filtering before the reporter matters, which I hadn't been able to quantify earlier.

The OpenTelemetry metrics SDK applies a cardinality limit per metric stream, defaulting to 2000 attribute combinations. Beyond it, the SDK doesn't drop measurements — it folds them into one data point marked otel.metric.overflow=true and strips the original attributes. Totals remain correct while the per-table breakdown silently degrades, with no error and no missing metric to notice.

That puts a number on the "per-table cardinality" concern from the #16250 dev@ thread: with a table-name attribute in play, more than ~2000 distinct tables is enough to lose the breakdown. Filtering at the framework layer addresses it at the source — the reports never become time series in the first place — whereas the SDK-side options are to raise the limit or drop the attribute wholesale, and the Collector-side option only exists for OTel users.

To be clear about scope: this is an argument for filtering existing somewhere, not for any particular shape of it. Your two concerns are still the open questions, and I'm happy to follow your read on Q1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core: Add table-level filtering for MetricsReporter implementations

2 participants