You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2026/07/09 17:15:06 UTC

(iceberg-go) branch main updated: feat(metrics): add report and wire types (#1236) (#1421)

This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 6c02f843 feat(metrics): add report and wire types (#1236) (#1421)
6c02f843 is described below

commit 6c02f843058bfedfb0b865ad8e5608a1c9abe329
Author: Tanmay Rauth <t_...@apple.com>
AuthorDate: Thu Jul 9 10:15:01 2026 -0700

    feat(metrics): add report and wire types (#1236) (#1421)
    
    Second PR of the metrics reporting framework. Adds the spec-compliant
    report types and their JSON wire format: ScanReport, CommitReport, the
    ScanMetricsResult/CommitMetricsResult metric sets, CounterResult and
    TimerResult, plus the ReportMetricsRequest envelope with the flattened
    report-type discriminator.
    
    Metric names, units, and the request shape match Java verbatim and are
    locked by round-trip interop tests against fixtures in the Java wire
    format. ScanReport.filter defaults to the always-true expression for v1;
    unknown metric keys are tolerated.
    
    Related: #1236
---
 metrics/commit_report.go            |  81 +++++++++++++++++
 metrics/request.go                  | 114 ++++++++++++++++++++++++
 metrics/result.go                   |  61 +++++++++++++
 metrics/scan_report.go              |  85 ++++++++++++++++++
 metrics/testdata/commit_report.json |  25 ++++++
 metrics/testdata/scan_report.json   |  24 +++++
 metrics/wire_test.go                | 170 ++++++++++++++++++++++++++++++++++++
 7 files changed, 560 insertions(+)

diff --git a/metrics/commit_report.go b/metrics/commit_report.go
new file mode 100644
index 00000000..795dbc8a
--- /dev/null
+++ b/metrics/commit_report.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+// CommitMetricsResult is the serializable set of commit metrics. Field names
+// and units match Java's CommitMetricsResult / CommitMetrics so the wire
+// format is identical across implementations. Unset metrics are omitted.
+//
+// The counter values are derived from the snapshot summary; note that several
+// of Java's commit-report metric names differ from iceberg-go's snapshot
+// summary keys (e.g. added-files-size-bytes vs the summary's added-files-size),
+// and the commit instrumentation is responsible for emitting them under these
+// Java names.
+type CommitMetricsResult struct {
+	TotalDuration *TimerResult   `json:"total-duration,omitempty"`
+	Attempts      *CounterResult `json:"attempts,omitempty"`
+
+	AddedDataFiles   *CounterResult `json:"added-data-files,omitempty"`
+	RemovedDataFiles *CounterResult `json:"removed-data-files,omitempty"`
+	TotalDataFiles   *CounterResult `json:"total-data-files,omitempty"`
+
+	AddedDeleteFiles   *CounterResult `json:"added-delete-files,omitempty"`
+	RemovedDeleteFiles *CounterResult `json:"removed-delete-files,omitempty"`
+	TotalDeleteFiles   *CounterResult `json:"total-delete-files,omitempty"`
+
+	AddedEqualityDeleteFiles   *CounterResult `json:"added-equality-delete-files,omitempty"`
+	RemovedEqualityDeleteFiles *CounterResult `json:"removed-equality-delete-files,omitempty"`
+
+	AddedPositionalDeleteFiles   *CounterResult `json:"added-positional-delete-files,omitempty"`
+	RemovedPositionalDeleteFiles *CounterResult `json:"removed-positional-delete-files,omitempty"`
+
+	AddedDVs   *CounterResult `json:"added-dvs,omitempty"`
+	RemovedDVs *CounterResult `json:"removed-dvs,omitempty"`
+
+	AddedRecords   *CounterResult `json:"added-records,omitempty"`
+	RemovedRecords *CounterResult `json:"removed-records,omitempty"`
+	TotalRecords   *CounterResult `json:"total-records,omitempty"`
+
+	AddedFilesSizeBytes   *CounterResult `json:"added-files-size-bytes,omitempty"`
+	RemovedFilesSizeBytes *CounterResult `json:"removed-files-size-bytes,omitempty"`
+	TotalFilesSizeBytes   *CounterResult `json:"total-files-size-bytes,omitempty"`
+
+	AddedPositionalDeletes   *CounterResult `json:"added-positional-deletes,omitempty"`
+	RemovedPositionalDeletes *CounterResult `json:"removed-positional-deletes,omitempty"`
+	TotalPositionalDeletes   *CounterResult `json:"total-positional-deletes,omitempty"`
+
+	AddedEqualityDeletes   *CounterResult `json:"added-equality-deletes,omitempty"`
+	RemovedEqualityDeletes *CounterResult `json:"removed-equality-deletes,omitempty"`
+	TotalEqualityDeletes   *CounterResult `json:"total-equality-deletes,omitempty"`
+
+	ManifestsCreated         *CounterResult `json:"manifests-created,omitempty"`
+	ManifestsReplaced        *CounterResult `json:"manifests-replaced,omitempty"`
+	ManifestsKept            *CounterResult `json:"manifests-kept,omitempty"`
+	ManifestEntriesProcessed *CounterResult `json:"manifest-entries-processed,omitempty"`
+}
+
+// CommitReport is emitted after a commit completes. It maps to the spec's
+// CommitReport schema.
+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"`
+}
diff --git a/metrics/request.go b/metrics/request.go
new file mode 100644
index 00000000..20d1eea5
--- /dev/null
+++ b/metrics/request.go
@@ -0,0 +1,114 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+)
+
+// Report-type discriminator values, matching Java's ReportType wire form.
+const (
+	reportTypeScan   = "scan-report"
+	reportTypeCommit = "commit-report"
+)
+
+// reportTypeField is the discriminator key in the request body.
+const reportTypeField = "report-type"
+
+// ReportMetricsRequest is the body POSTed to the catalog metrics endpoint
+// (POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics). It maps to
+// the spec's ReportMetricsRequest: a ScanReport or CommitReport flattened
+// alongside a "report-type" discriminator (the report's fields are siblings of
+// report-type, not nested).
+type ReportMetricsRequest struct {
+	Report MetricsReport
+}
+
+// NewReportMetricsRequest wraps a report for transport.
+func NewReportMetricsRequest(report MetricsReport) ReportMetricsRequest {
+	return ReportMetricsRequest{Report: report}
+}
+
+// MarshalJSON writes report-type plus the report's own fields at the top level.
+func (r ReportMetricsRequest) MarshalJSON() ([]byte, error) {
+	// Guard nil reports up front. This catches both an untyped nil and a typed
+	// nil pointer (e.g. (*ScanReport)(nil)), which would otherwise match the
+	// type switch below and marshal to a bare "null" — silently producing a
+	// report with no required fields instead of an error. Mirrors isNilReport
+	// used by the built-in reporters.
+	if isNilReport(r.Report) {
+		return nil, errors.New("metrics: cannot marshal nil report")
+	}
+
+	var reportType string
+	switch r.Report.(type) {
+	case ScanReport, *ScanReport:
+		reportType = reportTypeScan
+	case CommitReport, *CommitReport:
+		reportType = reportTypeCommit
+	default:
+		return nil, fmt.Errorf("metrics: cannot marshal report of type %T", r.Report)
+	}
+
+	body, err := json.Marshal(r.Report)
+	if err != nil {
+		return nil, err
+	}
+
+	fields := map[string]json.RawMessage{}
+	if err := json.Unmarshal(body, &fields); err != nil {
+		return nil, err
+	}
+	fields[reportTypeField], _ = json.Marshal(reportType)
+
+	return json.Marshal(fields)
+}
+
+// UnmarshalJSON reads the report-type discriminator and decodes the matching
+// concrete report.
+func (r *ReportMetricsRequest) UnmarshalJSON(data []byte) error {
+	var disc struct {
+		ReportType string `json:"report-type"`
+	}
+	if err := json.Unmarshal(data, &disc); err != nil {
+		return err
+	}
+
+	switch disc.ReportType {
+	case reportTypeScan:
+		var sr ScanReport
+		if err := json.Unmarshal(data, &sr); err != nil {
+			return err
+		}
+		r.Report = sr
+	case reportTypeCommit:
+		var cr CommitReport
+		if err := json.Unmarshal(data, &cr); err != nil {
+			return err
+		}
+		r.Report = cr
+	case "":
+		return fmt.Errorf("metrics: missing %q in metrics request", reportTypeField)
+	default:
+		return fmt.Errorf("metrics: unknown report-type %q", disc.ReportType)
+	}
+
+	return nil
+}
diff --git a/metrics/result.go b/metrics/result.go
new file mode 100644
index 00000000..7a0cad26
--- /dev/null
+++ b/metrics/result.go
@@ -0,0 +1,61 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+// Unit identifies what a counter measures. The wire values match Java's
+// MetricsContext.Unit display names.
+type Unit string
+
+const (
+	UnitUndefined Unit = "undefined"
+	UnitCount     Unit = "count"
+	UnitBytes     Unit = "bytes"
+)
+
+// TimeUnit values for a TimerResult, matching Java's lowercased
+// java.util.concurrent.TimeUnit names. iceberg-go records durations in
+// nanoseconds.
+const (
+	TimeUnitNanoseconds = "nanoseconds"
+)
+
+// CounterResult is the serializable snapshot of a single counter. It maps to
+// the spec's CounterResult schema.
+type CounterResult struct {
+	Unit  Unit  `json:"unit"`
+	Value int64 `json:"value"`
+}
+
+// NewCounterResult returns a CounterResult with the given unit and value.
+func NewCounterResult(unit Unit, value int64) *CounterResult {
+	return &CounterResult{Unit: unit, Value: value}
+}
+
+// TimerResult is the serializable snapshot of a single timer. It maps to the
+// spec's TimerResult schema. TotalDuration is expressed in TimeUnit.
+type TimerResult struct {
+	TimeUnit      string `json:"time-unit"`
+	Count         int64  `json:"count"`
+	TotalDuration int64  `json:"total-duration"`
+}
+
+// NewNanosTimerResult returns a TimerResult recording totalNanos nanoseconds
+// observed over count samples.
+func NewNanosTimerResult(count, totalNanos int64) *TimerResult {
+	return &TimerResult{TimeUnit: TimeUnitNanoseconds, Count: count, TotalDuration: totalNanos}
+}
diff --git a/metrics/scan_report.go b/metrics/scan_report.go
new file mode 100644
index 00000000..d0e10405
--- /dev/null
+++ b/metrics/scan_report.go
@@ -0,0 +1,85 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+import "encoding/json"
+
+// alwaysTrueFilter is the structured Expression emitted for ScanReport.filter
+// when no filter is set. Iceberg serializes the always-true expression as the
+// bare JSON boolean true (see AlwaysTrue.MarshalJSON in the root package, which
+// mirrors Java's ExpressionParser); a {"type":...} object is used only for
+// predicates and and/or/not nodes. Emitting anything else here would be
+// rejected as invalid Expression JSON by a spec-compliant catalog.
+var alwaysTrueFilter = json.RawMessage(`true`)
+
+// ScanMetricsResult is the serializable set of scan-planning metrics. Field
+// names and units match Java's ScanMetricsResult so the wire format is
+// identical across implementations. Unset metrics are omitted.
+type ScanMetricsResult struct {
+	TotalPlanningDuration      *TimerResult   `json:"total-planning-duration,omitempty"`
+	ResultDataFiles            *CounterResult `json:"result-data-files,omitempty"`
+	ResultDeleteFiles          *CounterResult `json:"result-delete-files,omitempty"`
+	TotalDataManifests         *CounterResult `json:"total-data-manifests,omitempty"`
+	TotalDeleteManifests       *CounterResult `json:"total-delete-manifests,omitempty"`
+	ScannedDataManifests       *CounterResult `json:"scanned-data-manifests,omitempty"`
+	ScannedDeleteManifests     *CounterResult `json:"scanned-delete-manifests,omitempty"`
+	SkippedDataManifests       *CounterResult `json:"skipped-data-manifests,omitempty"`
+	SkippedDeleteManifests     *CounterResult `json:"skipped-delete-manifests,omitempty"`
+	SkippedDataFiles           *CounterResult `json:"skipped-data-files,omitempty"`
+	SkippedDeleteFiles         *CounterResult `json:"skipped-delete-files,omitempty"`
+	TotalFileSizeInBytes       *CounterResult `json:"total-file-size-in-bytes,omitempty"`
+	TotalDeleteFileSizeInBytes *CounterResult `json:"total-delete-file-size-in-bytes,omitempty"`
+	IndexedDeleteFiles         *CounterResult `json:"indexed-delete-files,omitempty"`
+	EqualityDeleteFiles        *CounterResult `json:"equality-delete-files,omitempty"`
+	PositionalDeleteFiles      *CounterResult `json:"positional-delete-files,omitempty"`
+	DVs                        *CounterResult `json:"dvs,omitempty"`
+}
+
+// ScanReport is emitted after scan planning completes. It maps to the spec's
+// ScanReport schema. Filter holds the scan filter as Expression JSON (a bare
+// boolean for always-true/false, or a {"type":...} object for predicates and
+// and/or/not nodes); when unset it marshals as always-true (see
+// [alwaysTrueFilter]).
+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"`
+	Filter              json.RawMessage   `json:"filter"`
+	Metrics             ScanMetricsResult `json:"metrics"`
+	Metadata            map[string]string `json:"metadata,omitempty"`
+}
+
+// MarshalJSON defaults an unset Filter to the "always true" expression so the
+// spec-required filter field is always present.
+func (s ScanReport) MarshalJSON() ([]byte, error) {
+	type alias ScanReport
+	a := alias(s)
+	if len(a.Filter) == 0 {
+		a.Filter = alwaysTrueFilter
+	}
+	if a.ProjectedFieldIDs == nil {
+		a.ProjectedFieldIDs = []int{}
+	}
+	if a.ProjectedFieldNames == nil {
+		a.ProjectedFieldNames = []string{}
+	}
+
+	return json.Marshal(a)
+}
diff --git a/metrics/testdata/commit_report.json b/metrics/testdata/commit_report.json
new file mode 100644
index 00000000..22f72792
--- /dev/null
+++ b/metrics/testdata/commit_report.json
@@ -0,0 +1,25 @@
+{
+  "report-type": "commit-report",
+  "table-name": "nyc.taxis",
+  "snapshot-id": 3450729581828559851,
+  "sequence-number": 2,
+  "operation": "append",
+  "metrics": {
+    "total-duration": {
+      "count": 1,
+      "time-unit": "nanoseconds",
+      "total-duration": 1234567890
+    },
+    "attempts": {"unit": "count", "value": 1},
+    "added-data-files": {"unit": "count", "value": 4},
+    "total-data-files": {"unit": "count", "value": 4},
+    "added-records": {"unit": "count", "value": 12345},
+    "total-records": {"unit": "count", "value": 12345},
+    "added-files-size-bytes": {"unit": "bytes", "value": 4096000},
+    "total-files-size-bytes": {"unit": "bytes", "value": 4096000},
+    "manifests-created": {"unit": "count", "value": 1},
+    "manifests-kept": {"unit": "count", "value": 0},
+    "manifest-entries-processed": {"unit": "count", "value": 4}
+  },
+  "metadata": {"engine": "go"}
+}
diff --git a/metrics/testdata/scan_report.json b/metrics/testdata/scan_report.json
new file mode 100644
index 00000000..13a9e031
--- /dev/null
+++ b/metrics/testdata/scan_report.json
@@ -0,0 +1,24 @@
+{
+  "report-type": "scan-report",
+  "table-name": "nyc.taxis",
+  "snapshot-id": 3450729581828559851,
+  "schema-id": 0,
+  "projected-field-ids": [1, 2, 3],
+  "projected-field-names": ["vendor_id", "trip_distance", "fare_amount"],
+  "filter": true,
+  "metrics": {
+    "total-planning-duration": {
+      "count": 1,
+      "time-unit": "nanoseconds",
+      "total-duration": 2644235116
+    },
+    "result-data-files": {"unit": "count", "value": 1},
+    "result-delete-files": {"unit": "count", "value": 0},
+    "total-data-manifests": {"unit": "count", "value": 1},
+    "scanned-data-manifests": {"unit": "count", "value": 1},
+    "skipped-data-manifests": {"unit": "count", "value": 0},
+    "total-file-size-in-bytes": {"unit": "bytes", "value": 10},
+    "total-delete-file-size-in-bytes": {"unit": "bytes", "value": 0}
+  },
+  "metadata": {"engine": "go"}
+}
diff --git a/metrics/wire_test.go b/metrics/wire_test.go
new file mode 100644
index 00000000..8ab45295
--- /dev/null
+++ b/metrics/wire_test.go
@@ -0,0 +1,170 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package metrics
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/apache/iceberg-go"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// remarshalEqual asserts that JSON in -> value -> JSON out is semantically
+// identical (key order / whitespace independent).
+func assertJSONEqual(t *testing.T, want, got []byte) {
+	t.Helper()
+	var w, g any
+	require.NoError(t, json.Unmarshal(want, &w))
+	require.NoError(t, json.Unmarshal(got, &g))
+	assert.Equal(t, w, g)
+}
+
+func TestReportMetricsRequest_ScanInterop(t *testing.T) {
+	fixture, err := os.ReadFile(filepath.Join("testdata", "scan_report.json"))
+	require.NoError(t, err)
+
+	var req ReportMetricsRequest
+	require.NoError(t, json.Unmarshal(fixture, &req))
+
+	sr, ok := req.Report.(ScanReport)
+	require.True(t, ok, "expected ScanReport, got %T", req.Report)
+
+	// Spot-check decoded fields.
+	assert.Equal(t, "nyc.taxis", sr.TableName)
+	assert.Equal(t, int64(3450729581828559851), sr.SnapshotID)
+	assert.Equal(t, []int{1, 2, 3}, sr.ProjectedFieldIDs)
+	assert.JSONEq(t, `true`, string(sr.Filter))
+	require.NotNil(t, sr.Metrics.TotalPlanningDuration)
+	assert.Equal(t, TimeUnitNanoseconds, sr.Metrics.TotalPlanningDuration.TimeUnit)
+	assert.Equal(t, int64(2644235116), sr.Metrics.TotalPlanningDuration.TotalDuration)
+	require.NotNil(t, sr.Metrics.TotalFileSizeInBytes)
+	assert.Equal(t, UnitBytes, sr.Metrics.TotalFileSizeInBytes.Unit)
+	assert.Equal(t, int64(10), sr.Metrics.TotalFileSizeInBytes.Value)
+	assert.Equal(t, map[string]string{"engine": "go"}, sr.Metadata)
+
+	// Round-trip must reproduce the fixture exactly (semantically).
+	out, err := json.Marshal(req)
+	require.NoError(t, err)
+	assertJSONEqual(t, fixture, out)
+}
+
+func TestReportMetricsRequest_CommitInterop(t *testing.T) {
+	fixture, err := os.ReadFile(filepath.Join("testdata", "commit_report.json"))
+	require.NoError(t, err)
+
+	var req ReportMetricsRequest
+	require.NoError(t, json.Unmarshal(fixture, &req))
+
+	cr, ok := req.Report.(CommitReport)
+	require.True(t, ok, "expected CommitReport, got %T", req.Report)
+
+	assert.Equal(t, "nyc.taxis", cr.TableName)
+	assert.Equal(t, int64(2), cr.SequenceNumber)
+	assert.Equal(t, "append", cr.Operation)
+	require.NotNil(t, cr.Metrics.AddedRecords)
+	assert.Equal(t, int64(12345), cr.Metrics.AddedRecords.Value)
+	require.NotNil(t, cr.Metrics.AddedFilesSizeBytes)
+	assert.Equal(t, UnitBytes, cr.Metrics.AddedFilesSizeBytes.Unit)
+	require.NotNil(t, cr.Metrics.TotalDuration)
+	assert.Equal(t, int64(1234567890), cr.Metrics.TotalDuration.TotalDuration)
+
+	out, err := json.Marshal(req)
+	require.NoError(t, err)
+	assertJSONEqual(t, fixture, out)
+}
+
+func TestScanReport_DefaultsFilterToAlwaysTrue(t *testing.T) {
+	// A ScanReport with no Filter must still emit the spec-required filter field.
+	sr := ScanReport{TableName: "t", SnapshotID: 1}
+	out, err := json.Marshal(sr)
+	require.NoError(t, err)
+
+	var decoded map[string]json.RawMessage
+	require.NoError(t, json.Unmarshal(out, &decoded))
+	require.Contains(t, decoded, "filter")
+	assert.JSONEq(t, `true`, string(decoded["filter"]))
+	// Empty projected lists serialize as [] (not null) to match the spec arrays.
+	assert.JSONEq(t, `[]`, string(decoded["projected-field-ids"]))
+	assert.JSONEq(t, `[]`, string(decoded["projected-field-names"]))
+}
+
+// TestScanReport_FilterMatchesCanonicalAlwaysTrue pins the default filter to
+// iceberg's canonical always-true wire form (a bare JSON boolean) rather than a
+// hand-written literal, so the two can't silently drift. A {"type":...} object
+// or any other shape would be rejected as invalid Expression JSON by a
+// spec-compliant catalog.
+func TestScanReport_FilterMatchesCanonicalAlwaysTrue(t *testing.T) {
+	want, err := json.Marshal(iceberg.AlwaysTrue{})
+	require.NoError(t, err)
+
+	out, err := json.Marshal(ScanReport{TableName: "t"})
+	require.NoError(t, err)
+	var decoded map[string]json.RawMessage
+	require.NoError(t, json.Unmarshal(out, &decoded))
+	assert.JSONEq(t, string(want), string(decoded["filter"]))
+}
+
+func TestReportMetricsRequest_MarshalDiscriminator(t *testing.T) {
+	scanOut, err := json.Marshal(NewReportMetricsRequest(ScanReport{TableName: "t"}))
+	require.NoError(t, err)
+	var m map[string]json.RawMessage
+	require.NoError(t, json.Unmarshal(scanOut, &m))
+	assert.JSONEq(t, `"scan-report"`, string(m["report-type"]))
+	assert.Contains(t, m, "table-name", "report fields are flattened, not nested")
+
+	commitOut, err := json.Marshal(NewReportMetricsRequest(CommitReport{TableName: "t"}))
+	require.NoError(t, err)
+	require.NoError(t, json.Unmarshal(commitOut, &m))
+	assert.JSONEq(t, `"commit-report"`, string(m["report-type"]))
+}
+
+func TestReportMetricsRequest_MarshalErrors(t *testing.T) {
+	_, err := json.Marshal(NewReportMetricsRequest(nil))
+	assert.Error(t, err, "nil report must not marshal")
+
+	// A typed-nil pointer is a non-nil interface, so it must be rejected too
+	// rather than silently marshaling to a report with no fields.
+	_, err = json.Marshal(NewReportMetricsRequest((*ScanReport)(nil)))
+	assert.Error(t, err, "typed-nil report must not marshal")
+}
+
+func TestReportMetricsRequest_UnmarshalErrors(t *testing.T) {
+	var req ReportMetricsRequest
+	assert.Error(t, json.Unmarshal([]byte(`{}`), &req), "missing report-type")
+	assert.Error(t, json.Unmarshal([]byte(`{"report-type":"bogus"}`), &req), "unknown report-type")
+}
+
+func TestScanMetricsResult_ToleratesUnknownKeys(t *testing.T) {
+	// Readers must tolerate metric keys they don't recognize (the spec metrics
+	// field is an open map) — unknown keys are ignored, not an error.
+	in := []byte(`{
+		"report-type":"scan-report",
+		"table-name":"t","snapshot-id":1,"schema-id":0,
+		"projected-field-ids":[],"projected-field-names":[],"filter":{"type":"true"},
+		"metrics":{"result-data-files":{"unit":"count","value":2},"some-future-metric":{"unit":"count","value":9}}
+	}`)
+	var req ReportMetricsRequest
+	require.NoError(t, json.Unmarshal(in, &req))
+	sr := req.Report.(ScanReport)
+	require.NotNil(t, sr.Metrics.ResultDataFiles)
+	assert.Equal(t, int64(2), sr.Metrics.ResultDataFiles.Value)
+}