tanmayrauth opened a new issue, #1236:
URL: https://github.com/apache/iceberg-go/issues/1236
### Feature Request / Improvement
## Summary
Today Java ships Iceberg's **Metrics Reporting API**, PyIceberg has none of
it, and neither does Go. This proposal builds the **full framework** in
iceberg-go, modeled on Java's long-standing implementation: a pluggable
`Reporter` that receives a `ScanReport` after scan planning and a
`CommitReport` after a commit, the `Counter`/`Timer`/`Context` primitives that
feed them, the scan- and commit-path instrumentation that populates them, and
the built-in `Logging`, `InMemory`, and `REST` reporters.
**OpenTelemetry is the final, optional piece** — one opt-in adapter layered
on top, not the core abstraction. The framework is the spec-compliant contract;
any backend (including OTel) plugs in behind it.
## Upstream alignment
**Framework (PRs 1–6) — modeled on Java's existing implementation.** Java
has shipped the full Metrics Reporting framework for years: `MetricsReporter`,
`ScanReport`/`CommitReport`, `ScanMetrics`/`CommitMetrics`,
`Counter`/`Timer`/`MetricsContext`, the `Logging`/`InMemory`/`REST` reporters,
and the scan- and commit-path instrumentation that drives them. The bulk of
this proposal ports that proven design to Go, adapting mechanics to Go idioms
(e.g. a registry instead of reflection for reporter selection). There is no
recent "discussion" to cite for this part — it predates the current dev
process, so the reference is the Java codebase itself.
**OTel adapter (PR 7) — informed by the recent Java dev-list thread.** The
one piece Java is adding *now* is an OpenTelemetry reporter ([issue
#16169](https://github.com/apache/iceberg/issues/16169), [PR
#16250](https://github.com/apache/iceberg/pull/16250), [dev-list
thread](https://lists.apache.org/thread/vn4gglocg2g40p69mfrrh86qzkn1rr4b)).
Since OTel is likewise the last/optional piece for us, we mirror the
conclusions that thread settled *for the OTel layer specifically*: the host
owns the SDK (the reporter just looks up a meter), cardinality controls (no
snapshot ID as a metric attribute; configurable attribute allowlist), the
curated OTel metric-name subset, and deferring span-based reporting. The one
mechanism we *don't* copy is Java's `compileOnly` dependency: iceberg-go
**already** pulls `go.opentelemetry.io` in transitively (via its GCP
integrations), so the OTel reporter is a plain in-module `metrics/otel` package
rather than a separate module — non-users pay no run
time cost because Go compiles only the packages a build actually imports.
## Motivation
Scan- and commit-planning metrics (files/manifests considered, scanned,
skipped; bytes read; commit attempts and durations) are otherwise invisible
from outside the client. A standard reporting interface lets operators
aggregate these across many clients, and a standard *naming* scheme lets
dashboards line up across Iceberg implementations. Today only Java ships this;
Go has nothing.
## Design decisions
1. **Spec-compliant interface as the core abstraction.** The catalog spec
defines a metrics endpoint that bundles metrics into a
`ScanReport`/`CommitReport`. We implement that contract so REST catalogs
interoperate, rather than emitting ad-hoc metrics straight from the library.
2. **OpenTelemetry is an opt-in adapter, not the core.** The OTel reporter
is an isolated `metrics/otel` package that the host wires its own SDK into (the
reporter only looks up a meter). It is opt-in: a build that never imports it
compiles none of its code. Unlike Java — which used a `compileOnly` dependency
to keep OTel off the runtime classpath — iceberg-go **already** depends on
`go.opentelemetry.io` transitively (via its GCP integrations), so a separate
module buys nothing and would force the repo to become multi-module. A plain
in-module package is simpler and achieves the same opt-in goal.
3. **Two distinct layers for metric names.** The **report / REST wire**
carries the *full* set of metrics (kebab-case, from
`ScanMetricsResult`/`CommitMetricsResult`) for spec interop. The **OTel
surface** exposes a deliberately *curated subset* under `iceberg.scan.*` /
`iceberg.commit.*` names, matching Java's `OtelMetricsReporter` (PR #16250)
verbatim so dashboards line up across implementations. One source of truth (the
reports), two presentations.
4. **Cardinality controls from day one.** Never attach snapshot ID as a
metric attribute (it is unbounded). The OTel adapter takes a configurable
attribute allowlist (`iceberg.otel.metrics.attributes`); the default bounded
set is **scan = `table-name` + `schema-id`, commit = `table-name` +
`operation`** — matching the Java PR after its mid-review cardinality fix.
5. **Zero overhead when unconfigured.** With no reporter set, the metrics
context hands out no-op counters/timers so instrumented code paths cost nothing.
6. **Reporting must never block scans or commits.** `Report()` is invoked
inline at the scan/commit completion point, so reporters must return promptly.
Network-backed reporters (the REST reporter, and OTel push exporters) dispatch
the actual send on a background worker; a slow or failing sink must never stall
or fail a scan/commit. Reporter errors are logged and swallowed, never
propagated to the operation.
7. **`filter` serialization is deferred for v1.** The REST spec types
`ScanReport.filter` as a structured `Expression` object (Java uses
`ExpressionParser.toJson`). iceberg-go has **no** expression-JSON serializer
today — `BooleanExpression` (`exprs.go`) implements only `fmt.Stringer`.
Building structured Expression (de)serialization is a separate, non-trivial
effort (also needed for REST predicate pushdown in general), so **v1 emits
`AlwaysTrue` (`{"type":"true"}`) for `filter`** and leaves full serialization
to a follow-up; every other report field is fully populated. (PyIceberg has no
expression-JSON serializer either, so this is not a Go-specific gap.)
8. **Opt-in by default; no behavior change for existing users.** Unlike
engine-embedded Java (which defaults to `LoggingMetricsReporter`), iceberg-go
is a library. The default reporter is **no-op** — no logging, no network
traffic — until a user explicitly configures one via `metrics-reporter-impl` or
`WithReporter`. The REST reporter is likewise opt-in, never auto-enabled, so
existing REST-catalog users see no new `/metrics` POSTs unless they turn it on.
9. **Scan counters are concurrency-safe.** Scan planning reads manifests
concurrently — `collectManifestEntries` uses `errgroup` with a bounded worker
pool (`g.SetLimit`) and mutex-guarded shared state today. Scan metric counters
are therefore incremented from multiple goroutines and must be safe under
`-race`: implemented with `atomic.Int64`, or via per-worker accumulation merged
at the `PlanFiles` completion point (matching the scanner's existing
`manifestEntries` pattern). The `Counter` interface documents this contract.
## Scope
**In scope**
- `metrics/` package: `Reporter`, `Report` marker,
`Counter`/`Timer`/`Context` primitives, report + result types, JSON wire format.
- Default reporters: `LoggingReporter`, `InMemoryReporter`, `Combine`
(composite).
- Reporter configuration (`metrics-reporter-impl` property + registry, plus
programmatic option) and threading through catalog → table → scan.
- Scan-path and commit-path instrumentation.
- `RESTMetricsReporter` implementing the catalog metrics endpoint.
- OpenTelemetry adapter as an isolated in-module `metrics/otel` package.
**Out of scope (deferred)**
- Span/trace-based reporting — belongs in a dedicated scan/commit
instrumentation hook; Java deferred it too.
- Histograms (unused by scan/commit reports).
- Server-side metrics aggregation — the catalog is not a metrics platform.
## Current state of iceberg-go
- **No metrics framework exists** — no `Reporter`, `ScanReport`,
`CommitReport`, `Context`, `Counter`, or `Timer`.
- **Commit data is already half-computed**: `updateMetrics`
(`table/snapshots.go`) tracks 16 `int64` counters (added/removed data files,
delete files, records, sizes, positional/equality deletes) for the snapshot
summary.
- **The REST metrics endpoint is a stub**: `endpointReportMetrics` (`POST
.../tables/{table}/metrics`, `catalog/rest/endpoints.go`) is declared but has
no handler or client call.
- **No direct OpenTelemetry usage** in source.
So commit metrics are mostly a matter of *surfacing* existing data; scan
metrics and the framework are net-new.
## Proposed architecture
```
metrics/ // core package (no new deps)
reporter.go // Reporter, Report marker, Combine, Logging,
InMemory
context.go // Context, Counter, Timer (+ no-op variants)
scan_report.go // ScanReport, ScanMetrics (live), ScanMetricsResult
commit_report.go // CommitReport, CommitMetrics (live),
CommitMetricsResult
result.go // CounterResult, TimerResult
request.go // ReportMetricsRequest envelope (REST)
metrics/otel/ // in-module package, opt-in (OTel already a
transitive dep)
reporter.go // OtelReporter, attribute allowlist
```
### Core interfaces
```go
package metrics
// Reporter is a pluggable sink for metrics reports.
type Reporter interface {
Report(ctx context.Context, report Report)
}
// Report is implemented by ScanReport and CommitReport.
type Report interface{ isMetricsReport() }
type Unit string
const (
UnitCount Unit = "count"
UnitBytes Unit = "bytes"
)
// Counter and Timer are the live metric primitives. The no-op
// implementations returned by a nil/absent reporter make instrumentation
free.
type Counter interface {
Incr()
Add(n int64)
Value() int64
Unit() Unit
}
type Timer interface{ Start() (stop func()) }
// Context produces named counters/timers for one scan or commit.
type Context interface {
Counter(name string, unit Unit) Counter
Timer(name string) Timer
}
```
### Report types (JSON = spec wire format)
Verified against `apache/iceberg`'s `open-api/rest-catalog-open-api.yaml`:
`ReportMetricsRequest` is `anyOf[ScanReport, CommitReport]` plus a required
`report-type` discriminator; `metrics` is an open map of `string →
(CounterResult | TimerResult)` (so readers must tolerate unknown metric keys).
```go
type ScanReport struct {
TableName string `json:"table-name"`
SnapshotID int64 `json:"snapshot-id"`
SchemaID int `json:"schema-id"`
ProjectedFieldIDs []int `json:"projected-field-ids"`
ProjectedFieldNames []string `json:"projected-field-names"`
// Spec type is a structured Expression object, not a string. iceberg-go
has no
// Expression JSON serializer yet (BooleanExpression only implements
Stringer),
// so v1 emits AlwaysTrue ({"type":"true"}). See design decision #7.
Filter json.RawMessage `json:"filter"`
Metrics ScanMetricsResult `json:"metrics"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func (ScanReport) isMetricsReport() {}
type CommitReport struct {
TableName string `json:"table-name"`
SnapshotID int64 `json:"snapshot-id"`
SequenceNumber int64 `json:"sequence-number"`
Operation string `json:"operation"`
Metrics CommitMetricsResult `json:"metrics"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func (CommitReport) isMetricsReport() {}
type CounterResult struct {
Unit Unit `json:"unit"`
Value int64 `json:"value"`
}
type TimerResult struct {
Count int64 `json:"count"`
TimeUnit string `json:"time-unit"`
TotalDuration int64 `json:"total-duration"`
}
type ScanMetricsResult struct {
TotalPlanningDuration *TimerResult
`json:"total-planning-duration,omitempty"`
ResultDataFiles *CounterResult `json:"result-data-files,omitempty"`
ResultDeleteFiles *CounterResult
`json:"result-delete-files,omitempty"`
// ... remaining scan metrics (table below)
}
```
### Configuration
```go
// String-configured (works for REST catalog config too):
cat, _ := catalog.Load(ctx, "prod", iceberg.Properties{
"metrics-reporter-impl": "logging", // or a registered name
})
// Programmatic (the idiomatic Go path):
cat, _ := rest.NewCatalog(ctx, "prod", uri,
rest.WithMetricsReporter(myReporter))
// Per-scan override:
scan := tbl.Scan(table.WithReporter(inmem))
```
Java selects a reporter by reflecting on a fully-qualified class name
(`metrics-reporter-impl=org.apache...`). Go has no equivalent runtime class
loading, so we use a small registry that maps a short name (`"logging"`,
`"rest"`) to a constructor, plus the programmatic `WithReporter` option as the
primary idiom.
## Metric names
There are two layers, and they intentionally differ:
1. **Report / REST wire** — the *full* set of metrics, kebab-case, taken
verbatim from Java's `ScanMetricsResult` / `CommitMetricsResult`. These are the
cross-implementation contract for the spec-compliant reports.
2. **OTel surface** — a deliberately *curated subset* (~12 series), named to
match Java's `OtelMetricsReporter` (PR #16250). The OTel reporter does **not**
export every report field; it maps the high-value ones so backends like
Prometheus/CloudWatch stay manageable.
### Report fields — scan (17, kebab-case wire names)
`total-planning-duration` (timer), `result-data-files`,
`result-delete-files`, `total-data-manifests`, `total-delete-manifests`,
`scanned-data-manifests`, `scanned-delete-manifests`, `skipped-data-manifests`,
`skipped-delete-manifests`, `skipped-data-files`, `skipped-delete-files`,
`total-file-size-in-bytes`, `total-delete-file-size-in-bytes`,
`indexed-delete-files`, `equality-delete-files`, `positional-delete-files`,
`dvs`.
### Report fields — commit (26, kebab-case wire names)
`total-duration` (timer), `attempts`, plus the summary-derived counters:
`added/removed/total-data-files`, `added/removed/total-delete-files`,
`added/removed/total-records`, `added/removed/total-files-size-bytes`,
`added/removed/total-positional-deletes`,
`added/removed/total-equality-deletes`, equality/positional delete-file
variants, `added/removed-dvs`, `manifests-created/replaced/kept`,
`manifest-entries-processed`.
### OTel surface (curated subset, matching Java PR #16250)
These are the names Java's `OtelMetricsReporter` actually emits — we mirror
them exactly:
| OTel metric | Kind / unit | Source report field |
|---|---|---|
| `iceberg.scan.planning.duration` | histogram, ms |
`total-planning-duration` |
| `iceberg.scan.result.data_files` | sum | `result-data-files` |
| `iceberg.scan.result.delete_files` | sum | `result-delete-files` |
| `iceberg.scan.data_manifests.scanned` | sum | `scanned-data-manifests` |
| `iceberg.scan.data_manifests.skipped` | sum | `skipped-data-manifests` |
| `iceberg.scan.file_size.bytes` | sum, By | `total-file-size-in-bytes` |
| `iceberg.commit.duration` | histogram, ms | `total-duration` |
| `iceberg.commit.attempts` | sum | `attempts` |
| `iceberg.commit.records.added` | sum | `added-records` |
| `iceberg.commit.data_files.added` | sum | `added-data-files` |
| `iceberg.commit.data_files.removed` | sum | `removed-data-files` |
| `iceberg.commit.file_size.added_bytes` | sum, By |
`added-files-size-bytes` |
**OTel attributes:** configurable via the `iceberg.otel.metrics.attributes`
allowlist. Default bounded set — scan: `iceberg.table.name` +
`iceberg.schema.id`; commit: `iceberg.table.name` + `iceberg.operation`.
`iceberg.snapshot.id` is deliberately **never** a metric attribute (unbounded
cardinality); per-snapshot detail stays available through the full reports and
snapshot history.
> **Naming reconciliation note:** iceberg-go's existing *snapshot-summary*
keys differ slightly from Java's *commit-report* names (e.g. `added-files-size`
vs `added-files-size-bytes`, `added-position-deletes` vs
`added-positional-deletes`, `deleted-data-files` vs `removed-data-files`). The
summary keys are spec-defined and stay as-is; the **report** uses Java's metric
names. The commit instrumentation computes report values from the existing
`updateMetrics` counters and emits them under the Java names.
>
> Feature-gated metrics (deletion vectors, equality/positional deletes) are
populated only where iceberg-go implements the underlying feature; otherwise
they are omitted rather than fabricated.
## Implementation plan (incremental, independently reviewable PRs)
| # | PR | Scope |
|---|---|---|
| 1 | **Metrics package foundation** | `Unit`, `Counter`, `Timer`, `Context`
(+ no-ops), `Reporter`, `Report` marker, `LoggingReporter`, `InMemoryReporter`,
`Combine`. New `metrics/` only. |
| 2 | **Report + wire types** | `ScanReport`/`CommitReport`,
`*MetricsResult`, `CounterResult`/`TimerResult`, `ReportMetricsRequest`,
kebab-case JSON + interop tests against Java-produced JSON. |
| 3 | **Plumbing** | `metrics-reporter-impl` + registry + programmatic
option; thread reporter through `Table` and the scan builder; default =
**no-op** (metrics are strictly opt-in — a library must not emit logs or
network calls unless asked). |
| 4 | **Scan instrumentation** | populate scan counters/timer in scan
planning, emit `ScanReport`. |
| 5 | **Commit instrumentation** | `total-duration` timer + `attempts`;
snapshot `updateMetrics` into a `CommitReport`; emit it. |
| 6 | **REST reporter** | implement the `endpointReportMetrics` stub:
**async** (non-blocking) POST of the spec-verified `ReportMetricsRequest` +
combine into the REST catalog. |
| 7 | **OpenTelemetry adapter + docs** | isolated in-module `metrics/otel`
package, meter mapping, configurable attribute allowlist, cardinality docs,
metrics-reporting page. |
**Critical path:** 1 → 2 → 3 → 4. PRs 5, 6, 7 fan out from PR 3 and can be
built in parallel.
PRs 1–6 are the framework — a Go port of machinery Java has had for years
(no live upstream PR to track). PR 7 is the only part with a current Java
analog (PR #16250), and it's deliberately the smallest and most isolated: an
opt-in `metrics/otel` package.
## Open questions
## Open questions
1. **Reporter selection** — registry + `metrics-reporter-impl` (for
spec/REST parity) *plus* a programmatic option? (proposed: both)
2. **OTel placement** — in-module `metrics/otel` package (proposed:
`go.opentelemetry.io` is already a transitive dependency via the GCP
integrations, so isolating it in a separate module buys nothing and would make
iceberg-go multi-module) vs. a separate Go module. Java kept its reporter in
`iceberg-core` via `compileOnly`; Go has no compile-only mechanism but doesn't
need one here because the dependency is already present.
3. **OTel metric naming** — Java's `iceberg.scan.*` / `iceberg.commit.*`
names (PR #16250) are the only concrete precedent, but Java itself flagged them
as **provisional** (issue #16169: no OTel semantic convention exists yet, would
realign if one emerges) and that PR is still unmerged. Mirror Java's current
names verbatim for parity (proposed), adopt the dotted
`iceberg.<operation>.<resource>.<count-type>` scheme, or wait for an OTel
semantic convention? And should the Go subset match Java's ~12 series exactly?
4. **Metric accumulation shape** — the proposal currently sketches Java's
exported `Counter`/`Timer`/`MetricsContext` primitives. The alternative is to
keep accumulation as a plain internal `atomic.Int64` struct — mirroring the
existing `table.updateMetrics` — and export only the `Reporter` + report/result
types, which is smaller and arguably more idiomatic Go.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]