You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by at...@apache.org on 2026/09/22 19:41:36 UTC

(iceberg-go) branch main updated: feat(rest): add scan-planning retries, telemetry, and documentation (#2020)

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

laskoviymishka 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 1857b7b00 feat(rest): add scan-planning retries, telemetry, and documentation (#2020)
1857b7b00 is described below

commit 1857b7b0097da80b9b3e4562484cc382f5b5436c
Author: Revanth <re...@gmail.com>
AuthorDate: Tue Sep 22 14:41:31 2026 -0500

    feat(rest): add scan-planning retries, telemetry, and documentation (#2020)
    
    * feat(rest): add scan-planning retries, telemetry, and documentation
    
    * fix(rest): address scan-planning review feedback
---
 README.md                                    | 108 +++++++++++-
 catalog/rest/scan_planning.go                |  78 ++++++---
 catalog/rest/scan_planning_parity_test.go    |   8 +-
 catalog/rest/scan_planning_retry.go          |  68 ++++++++
 catalog/rest/scan_planning_retry_test.go     | 239 +++++++++++++++++++++++++++
 catalog/rest/scan_planning_telemetry_test.go | 207 +++++++++++++++++++++++
 expr_json_test.go                            |  19 +++
 go.mod                                       |   4 +-
 internal/scanmetrics/telemetry.go            |  84 ++++++++++
 internal/scanmetrics/telemetry_test.go       |  96 +++++++++++
 table/scan_splits_test.go                    |  19 +++
 table/scanner.go                             |   8 +-
 12 files changed, 909 insertions(+), 29 deletions(-)

diff --git a/README.md b/README.md
index afb0cf1fa..23888222e 100644
--- a/README.md
+++ b/README.md
@@ -117,8 +117,112 @@ Tests requiring Spark 4 (currently the variant and unknown-type tests) automatic
 | Get Partition Specs      |     X     |
 | Get Manifests            |     X     |
 | Create New Manifests     |     X     |
-| Plan Scan                |     x     |
-| Plan Scan for Snapshot   |     x     |
+| Plan Scan                | Local + Remote |
+| Plan Scan for Snapshot   | Local + Remote |
+
+### REST Scan Planning
+
+REST catalogs can plan scans on the server, including asynchronous plans,
+batched task retrieval, and plan-scoped storage credentials. Enable this per
+scan with `table.WithScanPlanningMode`; existing scans continue to plan locally.
+
+| Scan option | Behavior |
+| :---------- | :------- |
+| `table.ScanPlanningLocal` (default) | Read manifests locally; no planning endpoints required. |
+| `table.ScanPlanningRemote` | Require the advertised plan endpoint. Async or fanout responses also require their continuation endpoints; missing capabilities return an error. |
+| `table.ScanPlanningAuto` | Use remote planning when submission, polling, and task retrieval endpoints are advertised; otherwise plan locally. Errors after choosing remote are returned, without falling back to local. |
+
+Capabilities come from `GET /v1/config`. `rest.Catalog.SupportsPlanTableScan()`
+checks submission support, while `SupportsFullRemoteScanPlanning()` checks
+submission, polling, and task retrieval. Cancellation is best-effort and is not
+required for automatic remote planning. In `auto` mode, catalogs
+without a planner retain local planning.
+
+The scanner does not honor the REST `scan-planning-mode` configuration key
+(`client`/`server`). A catalog requiring `server` mode may rely on planning to
+vend plan-scoped storage credentials. Default local planning, explicit `local`,
+or an `auto` fallback can instead read manifests with the table's storage
+credentials, which may use a different identity or lack the required access.
+For these deployments, explicitly select `table.ScanPlanningRemote` on every
+scan; do not rely on the server configuration or `auto` to enforce remote
+planning. Remote mode returns an error if a required capability is missing.
+
+```go
+// tbl is a table loaded from a rest.Catalog.
+ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+defer cancel()
+
+scan := tbl.Scan(
+    table.WithScanPlanningMode(table.ScanPlanningRemote),
+    table.WithRowFilter(iceberg.EqualTo(iceberg.Reference("tenant_id"), "acme")),
+    table.WithSelectedFields("id", "tenant_id"),
+)
+defer scan.Close()
+
+tasks, err := scan.PlanFiles(ctx)
+if err != nil {
+    return err
+}
+// Read using this scan so its plan-scoped credentials remain attached.
+_, records, err := scan.ReadTasks(ctx, tasks)
+if err != nil {
+    return err
+}
+for record, err := range records {
+    if err != nil {
+        return err
+    }
+    // Consume the Arrow record batch here.
+    record.Release()
+}
+```
+
+`WithSnapshotID`, `WithSnapshotAsOf`, and tag scans use the historical schema;
+branch scans use the current table schema. File ranges follow
+`read.split.target-size` for both local and remote planning. Remote incremental
+scans and `_last_updated_sequence_number` projection are not supported. For
+row-lineage scans requiring that column, `auto` chooses local planning and
+`remote` returns an error.
+
+Plan and task POSTs retry HTTP 408, 429, 500, 502, 503, and 504 up to three times
+with jittered backoff, reusing the same UUIDv7 idempotency key. Other HTTP errors,
+transport failures, and malformed successful responses are returned directly.
+`Retry-After` is honored up to the caller's deadline (or capped at five seconds
+without a deadline). A new explicit call generates a new key unless one is
+supplied by the caller. `WaitForPlanOptions` controls polling delays and retry
+limits; use a context deadline to bound the complete operation. Cancellation
+during polling and exhausted polling trigger best-effort server cleanup. Recognized expired
+plans return `rest.ErrPlanExpired`; expired task handles return
+`rest.ErrNoSuchPlanTask`. Callers decide whether to submit a new plan.
+
+Close the scan after consuming its tasks, including when tasks are never read.
+Plan-scoped credentials remain alive for active readers; closing or replacing
+the plan releases them when those readers finish. They cannot be renewed from
+the table-credentials endpoint. Once `ReadTasks` returns an iterator, iterate it
+(even if stopping early) to release its reader lease.
+
+OpenTelemetry uses the application's global tracer and meter providers, with
+scope `github.com/apache/iceberg-go/scan-planning`; no SDK is installed by the
+library. Logical endpoint calls emit `iceberg.scan.planning.<operation>` spans
+and the following instruments:
+
+| Instrument | Meaning |
+| :--------- | :------ |
+| `iceberg.scan.planning.requests` | Logical endpoint calls, labeled by operation and success/error outcome. |
+| `iceberg.scan.planning.request.duration` | Endpoint call duration including retries, in milliseconds. |
+| `iceberg.scan.planning.retries` | Additional attempts after transient HTTP failures. |
+| `iceberg.scan.planning.expirations` | Recognized expired plan or task responses. |
+| `iceberg.scan.planning.fallbacks` | Auto mode choosing local due to capability or row-lineage constraints. |
+
+Operations are `plan`, `fetch-result`, `fetch-tasks`, and `cancel`. Attributes
+contain only operation, outcome, or fallback reason; they omit table names,
+filters, tokens, credentials, and raw error messages. These client metrics
+complement the existing scan/commit metrics reporter.
+
+The in-process local/remote parity test runs with `go test ./catalog/rest`.
+For Java interoperability, run `make integration-rest-scan-planning` (Docker
+required). Remote planning is opt-in, with no migration required for existing
+local scans.
 
 ### Catalog Support
 
diff --git a/catalog/rest/scan_planning.go b/catalog/rest/scan_planning.go
index 93ddfb6d1..b8c974651 100644
--- a/catalog/rest/scan_planning.go
+++ b/catalog/rest/scan_planning.go
@@ -39,6 +39,7 @@ import (
 
 	"github.com/apache/iceberg-go"
 	"github.com/apache/iceberg-go/catalog"
+	"github.com/apache/iceberg-go/internal/scanmetrics"
 	iceio "github.com/apache/iceberg-go/io"
 	"github.com/apache/iceberg-go/table"
 	"github.com/google/uuid"
@@ -594,7 +595,17 @@ func marshalScanFilter(req table.ScanPlanningRequest) ([]byte, error) {
 // Any other status — including the empty status of a 200 with no body, which
 // bypasses the response UnmarshalJSON validation — returns an ErrRESTError so a
 // malformed response cannot masquerade as an empty completed plan.
-func (r *Catalog) PlanTableScan(ctx context.Context, ident table.Identifier, req PlanTableScanRequest) (PlanTableScanResponse, error) {
+//
+// Transient HTTP responses (408, 429, 500, 502, 503, 504) are retried up to
+// three times with jittered backoff and the same idempotency key. Retry-After
+// is honored, capped at five seconds when ctx has no deadline. Transport
+// failures and malformed successful responses are not retried.
+func (r *Catalog) PlanTableScan(ctx context.Context, ident table.Identifier, req PlanTableScanRequest) (result PlanTableScanResponse, err error) {
+	ctx, finish := scanmetrics.Start(ctx, "plan")
+	defer func() {
+		finish(err)
+	}()
+
 	if err := r.endpoints.check(endpointPlanTableScan); err != nil {
 		return PlanTableScanResponse{}, err
 	}
@@ -609,10 +620,12 @@ func (r *Catalog) PlanTableScan(ctx context.Context, ident table.Identifier, req
 		return PlanTableScanResponse{}, err
 	}
 
-	resp, err := doPost[PlanTableScanRequest, PlanTableScanResponse](
-		ctx, r.baseURI, path, req, r.cl,
-		map[int]error{http.StatusNotFound: ErrRESTError},
-		withHeaders(headers), withErrorTypeOverride(planTableScanErrorTypes))
+	resp, err := retryScanPlanning(ctx, "plan", func() (PlanTableScanResponse, error) {
+		return doPost[PlanTableScanRequest, PlanTableScanResponse](
+			ctx, r.baseURI, path, req, r.cl,
+			map[int]error{http.StatusNotFound: ErrRESTError},
+			withHeaders(headers), withErrorTypeOverride(planTableScanErrorTypes))
+	})
 	if err != nil {
 		return PlanTableScanResponse{}, err
 	}
@@ -641,7 +654,15 @@ func (r *Catalog) PlanTableScan(ctx context.Context, ident table.Identifier, req
 // namespace is catalog.ErrNoSuchTable / catalog.ErrNoSuchNamespace, so the poller
 // can tell retry-with-a-new-plan from abort. A bare or unrecognized 404 stays an
 // ambiguous ErrRESTError rather than being guessed as an expiry.
-func (r *Catalog) FetchPlanningResult(ctx context.Context, ident table.Identifier, planID string, opts FetchPlanningResultOptions) (FetchPlanningResultResponse, error) {
+func (r *Catalog) FetchPlanningResult(ctx context.Context, ident table.Identifier, planID string, opts FetchPlanningResultOptions) (result FetchPlanningResultResponse, err error) {
+	ctx, finish := scanmetrics.Start(ctx, "fetch-result")
+	defer func() {
+		if errors.Is(err, ErrPlanExpired) {
+			scanmetrics.Expired(ctx, "fetch-result")
+		}
+		finish(err)
+	}()
+
 	if err := r.endpoints.check(endpointFetchPlanResult); err != nil {
 		return FetchPlanningResultResponse{}, err
 	}
@@ -680,7 +701,9 @@ func (r *Catalog) FetchPlanningResult(ctx context.Context, ident table.Identifie
 // the session-default access-delegation header (cancel vends no credentials). A
 // 404 (already-expired or unknown plan) is not special-cased: cancel is
 // best-effort, so the generic REST error is acceptable.
-func (r *Catalog) CancelPlanning(ctx context.Context, ident table.Identifier, planID string) error {
+func (r *Catalog) CancelPlanning(ctx context.Context, ident table.Identifier, planID string) (err error) {
+	ctx, finish := scanmetrics.Start(ctx, "cancel")
+	defer func() { finish(err) }()
 	if err := r.endpoints.check(endpointCancelPlanning); err != nil {
 		return err
 	}
@@ -705,7 +728,17 @@ func (r *Catalog) CancelPlanning(ctx context.Context, ident table.Identifier, pl
 // vanishing. A bare or unrecognized 404 stays an ambiguous ErrRESTError. An
 // empty 200 body is rejected (requireBody) so a truncated response is not read
 // as a successfully completed empty task set.
-func (r *Catalog) FetchScanTasks(ctx context.Context, ident table.Identifier, req FetchScanTasksRequest) (FetchScanTasksResponse, error) {
+// Transient HTTP responses use the same retry policy as [Catalog.PlanTableScan],
+// retaining the idempotency key and plan-task handle across attempts.
+func (r *Catalog) FetchScanTasks(ctx context.Context, ident table.Identifier, req FetchScanTasksRequest) (result FetchScanTasksResponse, err error) {
+	ctx, finish := scanmetrics.Start(ctx, "fetch-tasks")
+	defer func() {
+		if errors.Is(err, ErrNoSuchPlanTask) {
+			scanmetrics.Expired(ctx, "fetch-tasks")
+		}
+		finish(err)
+	}()
+
 	if err := r.endpoints.check(endpointFetchScanTasks); err != nil {
 		return FetchScanTasksResponse{}, err
 	}
@@ -720,11 +753,13 @@ func (r *Catalog) FetchScanTasks(ctx context.Context, ident table.Identifier, re
 		return FetchScanTasksResponse{}, err
 	}
 
-	return doPost[FetchScanTasksRequest, FetchScanTasksResponse](
-		ctx, r.baseURI, path, req, r.cl,
-		map[int]error{http.StatusNotFound: ErrRESTError},
-		withHeaders(headers), withSuppressedHeaders(headerIcebergAccessDelegation),
-		withErrorTypeOverride(fetchScanTasksErrorTypes), requireBody())
+	return retryScanPlanning(ctx, "fetch-tasks", func() (FetchScanTasksResponse, error) {
+		return doPost[FetchScanTasksRequest, FetchScanTasksResponse](
+			ctx, r.baseURI, path, req, r.cl,
+			map[int]error{http.StatusNotFound: ErrRESTError},
+			withHeaders(headers), withSuppressedHeaders(headerIcebergAccessDelegation),
+			withErrorTypeOverride(fetchScanTasksErrorTypes), requireBody())
+	})
 }
 
 // WaitForPlan polls a submitted plan to completion using jittered backoff. It
@@ -845,6 +880,9 @@ func (r *Catalog) WaitForPlan(ctx context.Context, ident table.Identifier, planI
 
 			return CompletedPlanningResult{}, fmt.Errorf("waiting for plan %q on %v: %w", planID, ident, ctx.Err())
 		case <-timer.C:
+			if err != nil {
+				scanmetrics.Retry(ctx, "fetch-result")
+			}
 		}
 	}
 }
@@ -1056,8 +1094,8 @@ func idempotencyHeaderValue(idempotencyKey *string) (string, error) {
 		// Plan and task POSTs always send an idempotency key. The spec pins it
 		// to a UUIDv7 string (RFC 9562) so a server can key its dedup window off
 		// the embedded timestamp, matching Java's UUIDUtil.generateUuidV7(). A
-		// v4 key would be treated as undefined by such servers. There is no
-		// transport retry here, so nil means a fresh key for this call.
+		// v4 key would be treated as undefined by such servers. All automatic
+		// retries within this call reuse the generated key.
 		key, err := uuid.NewV7()
 		if err != nil {
 			return "", fmt.Errorf("generating idempotency key: %w", err)
@@ -1145,9 +1183,8 @@ type CompletedPlanningResult struct {
 type PlanTableScanRequest struct {
 	// IdempotencyKey is sent as the Idempotency-Key header, not in the JSON body.
 	// If set, it must be a UUIDv7 string (RFC 9562). If nil, a fresh UUIDv7 is
-	// generated per call and not returned, so passing nil and retrying on a
-	// transient error sends a different key each attempt and defeats server-side
-	// dedup: pass an explicit key to get idempotency across retries.
+	// generated per call and reused for automatic retries. Supply an explicit
+	// key to preserve idempotency across separate calls.
 	IdempotencyKey *string `json:"-"`
 	// AccessDelegation is sent as the X-Iceberg-Access-Delegation header, not
 	// in the JSON body. Nil uses the catalog default.
@@ -1286,9 +1323,8 @@ func decodePlanningError(raw json.RawMessage) *PlanningError {
 type FetchScanTasksRequest struct {
 	// IdempotencyKey is sent as the Idempotency-Key header, not in the JSON body.
 	// If set, it must be a UUIDv7 string (RFC 9562). If nil, a fresh UUIDv7 is
-	// generated per call and not returned, so passing nil and retrying on a
-	// transient error sends a different key each attempt and defeats server-side
-	// dedup: pass an explicit key to get idempotency across retries.
+	// generated per call and reused for automatic retries. Supply an explicit
+	// key to preserve idempotency across separate calls.
 	IdempotencyKey *string `json:"-"`
 
 	PlanTask string `json:"plan-task"`
diff --git a/catalog/rest/scan_planning_parity_test.go b/catalog/rest/scan_planning_parity_test.go
index 859e349d7..526f9ff52 100644
--- a/catalog/rest/scan_planning_parity_test.go
+++ b/catalog/rest/scan_planning_parity_test.go
@@ -219,8 +219,12 @@ func newPlanningParityTable(t *testing.T) (table.Metadata, *iceio.MemFS) {
 		}
 		snapshotID, seq := int64((i+1)*10), int64(i+1)
 		path := fmt.Sprintf("mem://parity/list-%d.avro", snapshotID)
+		var parentSnapshotID *int64
+		if i == 1 {
+			parentSnapshotID = parityPtr(int64(10))
+		}
 		var list bytes.Buffer
-		require.NoError(t, iceberg.WriteManifestList(2, &list, snapshotID, nil, &seq, 0, manifests))
+		require.NoError(t, iceberg.WriteManifestList(2, &list, snapshotID, parentSnapshotID, &seq, 0, manifests))
 		require.NoError(t, fs.WriteFile(path, list.Bytes()))
 		if i == 0 {
 			committed, err := iceberg.ReadManifestList(bytes.NewReader(list.Bytes()))
@@ -228,7 +232,7 @@ func newPlanningParityTable(t *testing.T) (table.Metadata, *iceio.MemFS) {
 			mf = committed[0]
 		}
 		require.NoError(t, builder.AddSnapshot(&table.Snapshot{
-			SnapshotID: snapshotID, SequenceNumber: seq,
+			SnapshotID: snapshotID, ParentSnapshotID: parentSnapshotID, SequenceNumber: seq,
 			TimestampMs: time.Now().UnixMilli() + int64(i+1), ManifestList: path, SchemaID: parityPtr(0),
 		}))
 	}
diff --git a/catalog/rest/scan_planning_retry.go b/catalog/rest/scan_planning_retry.go
new file mode 100644
index 000000000..79ace6471
--- /dev/null
+++ b/catalog/rest/scan_planning_retry.go
@@ -0,0 +1,68 @@
+// 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 rest
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/apache/iceberg-go/internal/scanmetrics"
+)
+
+// retryScanPlanning retries only explicit transient HTTP responses. In
+// particular, it never replays an ambiguous transport failure or a malformed
+// successful response. POST callers create their idempotency header before
+// entering this helper and reuse it (and the request body) for every attempt.
+func retryScanPlanning[T any](ctx context.Context, operation string, request func() (T, error)) (T, error) {
+	const (
+		maxRetries = 3
+		minDelay   = 100 * time.Millisecond
+		maxDelay   = 5 * time.Second
+	)
+	_, hasDeadline := ctx.Deadline()
+	delay := minDelay
+	for attempt := 0; ; attempt++ {
+		if err := ctx.Err(); err != nil {
+			var zero T
+
+			return zero, err
+		}
+		result, err := request()
+		var responseError errorResponse
+		if !errors.As(err, &responseError) {
+			return result, err
+		}
+		retryAfter, retryable := scanPlanPollRetry(err)
+		if !retryable || attempt == maxRetries {
+			return result, err
+		}
+		delay = nextScanPlanBackoff(delay, minDelay, maxDelay)
+		wait := applyRetryAfter(delay, retryAfter, minDelay, maxDelay, hasDeadline)
+		timer := time.NewTimer(wait)
+		select {
+		case <-ctx.Done():
+			timer.Stop()
+			var zero T
+
+			return zero, ctx.Err()
+		case <-timer.C:
+			scanmetrics.Retry(ctx, operation)
+		}
+	}
+}
diff --git a/catalog/rest/scan_planning_retry_test.go b/catalog/rest/scan_planning_retry_test.go
new file mode 100644
index 000000000..68f9e7fdb
--- /dev/null
+++ b/catalog/rest/scan_planning_retry_test.go
@@ -0,0 +1,239 @@
+// 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 rest
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"testing/synctest"
+	"time"
+
+	"github.com/apache/iceberg-go/table"
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestScanPlanningPOSTRetriesPreserveIdempotency(t *testing.T) {
+	t.Parallel()
+	for _, operation := range []string{"plan", "tasks"} {
+		for _, status := range []int{408, 429, 500, 502, 503, 504} {
+			t.Run(fmt.Sprintf("%s/%d", operation, status), func(t *testing.T) {
+				t.Parallel()
+				var attempts atomic.Int32
+				var firstKey, firstBody string
+				var mu sync.Mutex
+				cat := newScanPlanningTestCatalog(t, []endpoint{endpointPlanTableScan, endpointFetchScanTasks}, func(mux *http.ServeMux) {
+					mux.HandleFunc("/v1/namespaces/db/tables/tbl/"+operation, func(w http.ResponseWriter, req *http.Request) {
+						mu.Lock()
+						defer mu.Unlock()
+						key := req.Header.Get(headerIdempotencyKey)
+						body, err := io.ReadAll(req.Body)
+						require.NoError(t, err)
+						parsed, err := uuid.Parse(key)
+						require.NoError(t, err)
+						assert.Equal(t, uuid.Version(7), parsed.Version())
+						if attempts.Add(1) == 1 {
+							firstKey, firstBody = key, string(body)
+							w.WriteHeader(status)
+
+							return
+						}
+						assert.Equal(t, firstKey, key)
+						assert.Equal(t, firstBody, string(body))
+						if operation == "plan" {
+							_, _ = w.Write([]byte(`{"status":"completed","plan-id":"p"}`))
+						} else {
+							_, _ = w.Write([]byte(`{}`))
+						}
+					})
+				})
+				var err error
+				if operation == "plan" {
+					_, err = cat.PlanTableScan(t.Context(), table.Identifier{"db", "tbl"}, PlanTableScanRequest{})
+				} else {
+					_, err = cat.FetchScanTasks(t.Context(), table.Identifier{"db", "tbl"}, FetchScanTasksRequest{PlanTask: "opaque"})
+				}
+				require.NoError(t, err)
+				assert.Equal(t, int32(2), attempts.Load())
+			})
+		}
+	}
+}
+
+func TestScanPlanningPOSTDoesNotRetryTerminalResponses(t *testing.T) {
+	t.Parallel()
+	for _, status := range []int{400, 401, 403, 404, 501} {
+		t.Run(strconv.Itoa(status), func(t *testing.T) {
+			t.Parallel()
+			var attempts atomic.Int32
+			cat := newScanPlanningTestCatalog(t, []endpoint{endpointPlanTableScan}, func(mux *http.ServeMux) {
+				mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan", func(w http.ResponseWriter, req *http.Request) {
+					attempts.Add(1)
+					w.WriteHeader(status)
+					_, _ = w.Write([]byte(`invalid json`))
+				})
+			})
+			_, err := cat.PlanTableScan(t.Context(), table.Identifier{"db", "tbl"}, PlanTableScanRequest{})
+			require.Error(t, err)
+			assert.Equal(t, int32(1), attempts.Load())
+		})
+	}
+}
+
+func TestScanPlanningPOSTDoesNotRetryMalformedSuccess(t *testing.T) {
+	t.Parallel()
+	for _, operation := range []string{"plan", "tasks"} {
+		t.Run(operation, func(t *testing.T) {
+			t.Parallel()
+			var attempts atomic.Int32
+			cat := newScanPlanningTestCatalog(t, []endpoint{endpointPlanTableScan, endpointFetchScanTasks}, func(mux *http.ServeMux) {
+				mux.HandleFunc("POST /v1/namespaces/db/tables/tbl/"+operation, func(w http.ResponseWriter, _ *http.Request) {
+					attempts.Add(1)
+					_, _ = w.Write([]byte(`invalid json`))
+				})
+			})
+			var err error
+			if operation == "plan" {
+				_, err = cat.PlanTableScan(t.Context(), table.Identifier{"db", "tbl"}, PlanTableScanRequest{})
+			} else {
+				_, err = cat.FetchScanTasks(t.Context(), table.Identifier{"db", "tbl"}, FetchScanTasksRequest{PlanTask: "opaque"})
+			}
+			require.ErrorIs(t, err, ErrRESTError)
+			require.ErrorContains(t, err, "error decoding json payload")
+			var responseError errorResponse
+			assert.False(t, errors.As(err, &responseError))
+			assert.Equal(t, int32(1), attempts.Load())
+		})
+	}
+}
+
+func TestScanPlanningRetryBoundsAndCancellation(t *testing.T) {
+	t.Parallel()
+	t.Run("bounded", func(t *testing.T) {
+		t.Parallel()
+		attempts := 0
+		_, err := retryScanPlanning(t.Context(), "plan", func() (int, error) {
+			attempts++
+
+			return 0, errorResponse{statusCode: http.StatusServiceUnavailable, wrapping: ErrServiceUnavailable}
+		})
+		require.ErrorIs(t, err, ErrServiceUnavailable)
+		assert.Equal(t, 4, attempts)
+	})
+	t.Run("transport failure", func(t *testing.T) {
+		attempts := 0
+		want := errors.New("connection reset after submission")
+		_, err := retryScanPlanning(t.Context(), "plan", func() (int, error) {
+			attempts++
+
+			return 0, want
+		})
+		require.ErrorIs(t, err, want)
+		assert.Equal(t, 1, attempts)
+	})
+	t.Run("transport sentinel without HTTP response", func(t *testing.T) {
+		attempts := 0
+		_, err := retryScanPlanning(t.Context(), "plan", func() (int, error) {
+			attempts++
+
+			return 0, ErrServiceUnavailable
+		})
+		require.ErrorIs(t, err, ErrServiceUnavailable)
+		assert.Equal(t, 1, attempts)
+	})
+	t.Run("cancel during retry after", func(t *testing.T) {
+		ctx, cancel := context.WithCancel(t.Context())
+		defer cancel()
+		var attempts atomic.Int32
+		cat := newScanPlanningTestCatalog(t, []endpoint{endpointPlanTableScan}, func(mux *http.ServeMux) {
+			mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan", func(w http.ResponseWriter, req *http.Request) {
+				attempts.Add(1)
+				w.Header().Set("Retry-After", "3600")
+				w.WriteHeader(http.StatusServiceUnavailable)
+				cancel()
+			})
+		})
+		started := time.Now()
+		_, err := cat.PlanTableScan(ctx, table.Identifier{"db", "tbl"}, PlanTableScanRequest{})
+		require.ErrorIs(t, err, context.Canceled)
+		assert.Less(t, time.Since(started), time.Second)
+		assert.Equal(t, int32(1), attempts.Load())
+	})
+	t.Run("already cancelled", func(t *testing.T) {
+		ctx, cancel := context.WithCancel(t.Context())
+		cancel()
+		_, err := retryScanPlanning(ctx, "plan", func() (int, error) {
+			t.Fatal("request after cancellation")
+
+			return 0, nil
+		})
+		require.ErrorIs(t, err, context.Canceled)
+	})
+}
+
+func TestScanPlanningRetryAfterTiming(t *testing.T) {
+	t.Parallel()
+	for _, tc := range []struct {
+		name     string
+		deadline time.Duration
+		wait     time.Duration
+	}{
+		{name: "capped without deadline", wait: 5 * time.Second},
+		{name: "honored with deadline", deadline: 10 * time.Second, wait: 8 * time.Second},
+		{name: "deadline interrupts wait", deadline: 2 * time.Second, wait: 2 * time.Second},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			synctest.Test(t, func(t *testing.T) {
+				ctx := t.Context()
+				if tc.deadline != 0 {
+					var cancel context.CancelFunc
+					ctx, cancel = context.WithTimeout(ctx, tc.deadline)
+					defer cancel()
+				}
+				started := time.Now()
+				attempts := 0
+				result, err := retryScanPlanning(ctx, "plan", func() (int, error) {
+					attempts++
+					if attempts == 1 {
+						return 0, errorResponse{statusCode: http.StatusTooManyRequests, retryAfter: "8"}
+					}
+
+					return 42, nil
+				})
+				assert.Equal(t, tc.wait, time.Since(started))
+				if tc.deadline != 0 && tc.deadline < 8*time.Second {
+					require.ErrorIs(t, err, context.DeadlineExceeded)
+					assert.Equal(t, 1, attempts)
+					assert.Zero(t, result)
+				} else {
+					require.NoError(t, err)
+					assert.Equal(t, 2, attempts)
+					assert.Equal(t, 42, result)
+				}
+			})
+		})
+	}
+}
diff --git a/catalog/rest/scan_planning_telemetry_test.go b/catalog/rest/scan_planning_telemetry_test.go
new file mode 100644
index 000000000..835b8210c
--- /dev/null
+++ b/catalog/rest/scan_planning_telemetry_test.go
@@ -0,0 +1,207 @@
+// 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 rest
+
+import (
+	"context"
+	"net/http"
+	"sync/atomic"
+	"testing"
+
+	"github.com/apache/iceberg-go"
+	iceio "github.com/apache/iceberg-go/io"
+	"github.com/apache/iceberg-go/table"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/codes"
+	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+	"go.opentelemetry.io/otel/sdk/metric/metricdata"
+	sdktrace "go.opentelemetry.io/otel/sdk/trace"
+	"go.opentelemetry.io/otel/sdk/trace/tracetest"
+	"go.opentelemetry.io/otel/trace"
+)
+
+// Keep this test serial: it temporarily replaces the global OTel providers.
+func TestScanPlanningTelemetryWiring(t *testing.T) {
+	// Deterministic sampling also makes accidental t.Parallel calls fail before
+	// the test can replace process-wide providers.
+	t.Setenv("OTEL_TRACES_SAMPLER", "always_on")
+	reader := sdkmetric.NewManualReader()
+	meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader),
+		sdkmetric.WithExemplarFilter(func(ctx context.Context) bool {
+			span := trace.SpanFromContext(ctx)
+			assert.True(t, span.IsRecording(), "metrics must be recorded before the operation span ends")
+
+			return span.SpanContext().IsSampled()
+		}))
+	recorder := tracetest.NewSpanRecorder()
+	tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
+	oldMeter, oldTracer := otel.GetMeterProvider(), otel.GetTracerProvider()
+	otel.SetMeterProvider(meter)
+	otel.SetTracerProvider(tracer)
+	t.Cleanup(func() {
+		otel.SetMeterProvider(oldMeter)
+		otel.SetTracerProvider(oldTracer)
+		require.NoError(t, meter.Shutdown(context.Background()))
+		require.NoError(t, tracer.Shutdown(context.Background()))
+	})
+
+	var plans, polls, tasks atomic.Int32
+	cat := newScanPlanningTestCatalog(t, []endpoint{endpointPlanTableScan, endpointFetchPlanResult, endpointFetchScanTasks, endpointCancelPlanning}, func(mux *http.ServeMux) {
+		mux.HandleFunc("POST /v1/namespaces/db/tables/tbl/plan", func(w http.ResponseWriter, _ *http.Request) {
+			if plans.Add(1) == 1 {
+				w.WriteHeader(http.StatusServiceUnavailable)
+
+				return
+			}
+			_, _ = w.Write([]byte(`{"status":"submitted","plan-id":"sensitive-plan"}`))
+		})
+		mux.HandleFunc("GET /v1/namespaces/db/tables/tbl/plan/sensitive-plan", func(w http.ResponseWriter, _ *http.Request) {
+			switch polls.Add(1) {
+			case 1:
+				w.WriteHeader(http.StatusServiceUnavailable)
+			case 2:
+				_, _ = w.Write([]byte(`{"status":"submitted"}`))
+			default:
+				_, _ = w.Write([]byte(`{"status":"completed"}`))
+			}
+		})
+		mux.HandleFunc("GET /v1/namespaces/db/tables/tbl/plan/expired", func(w http.ResponseWriter, _ *http.Request) {
+			w.WriteHeader(http.StatusNotFound)
+			_, _ = w.Write([]byte(`{"error":{"type":"NoSuchPlanIdException","message":"sensitive-plan","code":404}}`))
+		})
+		mux.HandleFunc("POST /v1/namespaces/db/tables/tbl/tasks", func(w http.ResponseWriter, _ *http.Request) {
+			if tasks.Add(1) == 1 {
+				w.WriteHeader(http.StatusServiceUnavailable)
+
+				return
+			}
+			w.WriteHeader(http.StatusNotFound)
+			_, _ = w.Write([]byte(`{"error":{"type":"NoSuchPlanTaskException","message":"sensitive-task","code":404}}`))
+		})
+		mux.HandleFunc("DELETE /v1/namespaces/db/tables/tbl/plan/sensitive-plan", func(w http.ResponseWriter, _ *http.Request) {
+			w.WriteHeader(http.StatusNoContent)
+		})
+	})
+	ctx, parent := tracer.Tracer("test").Start(t.Context(), "caller")
+	defer parent.End()
+	ident := table.Identifier{"db", "tbl"}
+	_, err := cat.PlanTableScan(ctx, ident, PlanTableScanRequest{})
+	require.NoError(t, err)
+	_, err = cat.WaitForPlan(ctx, ident, "sensitive-plan", fastWaitOpts)
+	require.NoError(t, err)
+	_, err = cat.FetchPlanningResult(ctx, ident, "expired", FetchPlanningResultOptions{})
+	require.ErrorIs(t, err, ErrPlanExpired)
+	_, err = cat.FetchScanTasks(ctx, ident, FetchScanTasksRequest{PlanTask: "sensitive-task"})
+	require.ErrorIs(t, err, ErrNoSuchPlanTask)
+	require.NoError(t, cat.CancelPlanning(ctx, ident, "sensitive-plan"))
+
+	// Exercise both scanner fallback paths and confirm explicit local scans
+	// don't count as fallbacks. The full-capability catalog must not be called.
+	meta, fs := newPlanningParityTable(t)
+	local := table.New(ident, meta, "metadata.json", func(context.Context) (iceio.IO, error) { return fs, nil }, nil)
+	capable := table.New(ident, meta, "metadata.json", func(context.Context) (iceio.IO, error) { return fs, nil }, cat)
+	for _, scan := range []*table.Scan{
+		local.Scan(),
+		local.Scan(table.WithScanPlanningMode(table.ScanPlanningAuto)),
+		capable.Scan(table.WithScanPlanningMode(table.ScanPlanningAuto), table.WithSelectedFields(iceberg.LastUpdatedSequenceNumberColumnName)),
+	} {
+		_, err := scan.PlanFiles(ctx)
+		require.NoError(t, err)
+		require.NoError(t, scan.Close())
+	}
+	assert.Equal(t, int32(2), plans.Load())
+	assert.Equal(t, int32(3), polls.Load())
+	assert.Equal(t, int32(2), tasks.Load())
+
+	spans := recorder.Ended()
+	require.Len(t, spans, 7, "POST retries belong to one logical span; each poll has its own")
+	outcomes := make(map[string]int)
+	for _, span := range spans {
+		assert.Equal(t, parent.SpanContext().SpanID(), span.Parent().SpanID())
+		assert.Equal(t, trace.SpanKindClient, span.SpanKind())
+		assert.Equal(t, "github.com/apache/iceberg-go/scan-planning", span.InstrumentationScope().Name)
+		require.Len(t, span.Attributes(), 1)
+		assert.Equal(t, attribute.Key("iceberg.scan.planning.operation"), span.Attributes()[0].Key)
+		assert.Empty(t, span.Events())
+		outcome := "success"
+		if span.Status().Code == codes.Error {
+			outcome = "error"
+			assert.Equal(t, "scan planning failed", span.Status().Description)
+		}
+		outcomes[span.Attributes()[0].Value.AsString()+"/"+outcome]++
+	}
+	assert.Equal(t, map[string]int{"plan/success": 1, "fetch-result/success": 2, "fetch-result/error": 2, "fetch-tasks/error": 1, "cancel/success": 1}, outcomes)
+
+	var resource metricdata.ResourceMetrics
+	require.NoError(t, reader.Collect(ctx, &resource))
+	counts := make(map[string]map[string]int64)
+	var durations uint64
+	for _, scope := range resource.ScopeMetrics {
+		for _, m := range scope.Metrics {
+			switch data := m.Data.(type) {
+			case metricdata.Sum[int64]:
+				counts[m.Name] = make(map[string]int64)
+				for _, point := range data.DataPoints {
+					op, _ := point.Attributes.Value(attribute.Key("iceberg.scan.planning.operation"))
+					outcome, _ := point.Attributes.Value(attribute.Key("iceberg.scan.planning.outcome"))
+					reason, _ := point.Attributes.Value(attribute.Key("iceberg.scan.planning.reason"))
+					key := op.AsString()
+					if m.Name == "iceberg.scan.planning.requests" {
+						assert.Equal(t, 2, point.Attributes.Len())
+						key += "/" + outcome.AsString()
+					} else {
+						assert.Equal(t, 1, point.Attributes.Len())
+					}
+					if m.Name == "iceberg.scan.planning.fallbacks" {
+						key = reason.AsString()
+					}
+					if m.Name == "iceberg.scan.planning.expirations" {
+						require.Len(t, point.Exemplars, 1)
+						exemplar := point.Exemplars[0]
+						var matched bool
+						for _, span := range spans {
+							if span.Name() == "iceberg.scan.planning."+op.AsString() && span.Status().Code == codes.Error {
+								spanID, traceID := span.SpanContext().SpanID(), span.SpanContext().TraceID()
+								if string(exemplar.SpanID) == string(spanID[:]) && string(exemplar.TraceID) == string(traceID[:]) {
+									matched = true
+								}
+							}
+						}
+						assert.True(t, matched, "expiration exemplar must identify its failed operation span")
+					}
+					counts[m.Name][key] += point.Value
+				}
+			case metricdata.Histogram[float64]:
+				assert.Equal(t, "ms", m.Unit)
+				for _, point := range data.DataPoints {
+					durations += point.Count
+				}
+			}
+		}
+	}
+	assert.Equal(t, map[string]map[string]int64{
+		"iceberg.scan.planning.requests":    {"plan/success": 1, "fetch-result/success": 2, "fetch-result/error": 2, "fetch-tasks/error": 1, "cancel/success": 1},
+		"iceberg.scan.planning.retries":     {"plan": 1, "fetch-result": 1, "fetch-tasks": 1},
+		"iceberg.scan.planning.expirations": {"fetch-result": 1, "fetch-tasks": 1},
+		"iceberg.scan.planning.fallbacks":   {"capability": 1, "row-lineage": 1},
+	}, counts)
+	assert.Equal(t, uint64(7), durations)
+}
diff --git a/expr_json_test.go b/expr_json_test.go
index 64a4519a1..69a4c0fed 100644
--- a/expr_json_test.go
+++ b/expr_json_test.go
@@ -668,3 +668,22 @@ func TestMarshalBoundNestedReferencePreservesPath(t *testing.T) {
 		})
 	}
 }
+
+func TestUnboundTransformRejectsWrappedReference(t *testing.T) {
+	t.Parallel()
+	// External wrappers can embed Reference, but binding
+	// rejects them before they can reach BoundTransform.MarshalJSON.
+	type wrappedReference struct {
+		iceberg.Reference
+	}
+	schema := iceberg.NewSchema(0,
+		iceberg.NestedField{ID: 1, Name: "Nested", Type: &iceberg.StructType{FieldList: []iceberg.NestedField{
+			{ID: 2, Name: "Value", Type: iceberg.PrimitiveTypes.Int32},
+		}}},
+	)
+	term := iceberg.NewUnboundTransform(iceberg.BucketTransform{NumBuckets: 16},
+		wrappedReference{iceberg.Reference("Nested.Value")})
+	_, err := term.Bind(schema, true)
+	require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+	require.ErrorContains(t, err, "transform terms must wrap a direct reference")
+}
diff --git a/go.mod b/go.mod
index 3169b1a21..09addd984 100644
--- a/go.mod
+++ b/go.mod
@@ -62,7 +62,9 @@ require (
 	github.com/uptrace/bun/extra/bundebug v1.2.18
 	go.opentelemetry.io/otel v1.46.0
 	go.opentelemetry.io/otel/metric v1.46.0
+	go.opentelemetry.io/otel/sdk v1.46.0
 	go.opentelemetry.io/otel/sdk/metric v1.46.0
+	go.opentelemetry.io/otel/trace v1.46.0
 	gocloud.dev v0.46.0
 	golang.org/x/exp v0.0.0-20260603202125-055de637280b
 	golang.org/x/oauth2 v0.36.0
@@ -257,8 +259,6 @@ require (
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect
 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect
-	go.opentelemetry.io/otel/sdk v1.46.0 // indirect
-	go.opentelemetry.io/otel/trace v1.46.0 // indirect
 	go.opentelemetry.io/proto/otlp v1.11.0 // indirect
 	go.yaml.in/yaml/v3 v3.0.5 // indirect
 	go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
diff --git a/internal/scanmetrics/telemetry.go b/internal/scanmetrics/telemetry.go
new file mode 100644
index 000000000..8dd63ac97
--- /dev/null
+++ b/internal/scanmetrics/telemetry.go
@@ -0,0 +1,84 @@
+// 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 scanmetrics instruments client-side scan planning. Attribute values
+// are bounded operation/outcome names; filters, credentials, table names, plan
+// tokens, paths, and server error messages are never exported.
+package scanmetrics
+
+import (
+	"context"
+	"time"
+
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/codes"
+	"go.opentelemetry.io/otel/metric"
+	"go.opentelemetry.io/otel/trace"
+)
+
+const scope = "github.com/apache/iceberg-go/scan-planning"
+
+// Start traces a logical operation, including its retries. Instruments come
+// from the application's global provider and are no-ops without an SDK.
+func Start(ctx context.Context, operation string) (context.Context, func(error)) {
+	ctx, span := otel.Tracer(scope).Start(ctx, "iceberg.scan.planning."+operation,
+		trace.WithSpanKind(trace.SpanKindClient),
+		trace.WithAttributes(attribute.String("iceberg.scan.planning.operation", operation)))
+	start := time.Now()
+
+	return ctx, func(err error) {
+		outcome := "success"
+		if err != nil {
+			outcome = "error"
+			// Error strings can include filters, paths, or opaque plan tokens.
+			span.SetStatus(codes.Error, "scan planning failed")
+		}
+		attrs := metric.WithAttributes(
+			attribute.String("iceberg.scan.planning.operation", operation),
+			attribute.String("iceberg.scan.planning.outcome", outcome))
+		meter := otel.Meter(scope)
+		if count, e := meter.Int64Counter("iceberg.scan.planning.requests"); e == nil {
+			count.Add(ctx, 1, attrs)
+		}
+		if duration, e := meter.Float64Histogram("iceberg.scan.planning.request.duration", metric.WithUnit("ms")); e == nil {
+			duration.Record(ctx, float64(time.Since(start))/float64(time.Millisecond), attrs)
+		}
+		span.End()
+	}
+}
+
+// Retry records an additional attempt after a transient HTTP failure.
+func Retry(ctx context.Context, operation string) {
+	count(ctx, "retries", "operation", operation)
+}
+
+// Expired records a recognized expired plan or plan-task response.
+func Expired(ctx context.Context, operation string) {
+	count(ctx, "expirations", "operation", operation)
+}
+
+// Fallback records auto mode choosing local planning before submitting a plan.
+func Fallback(ctx context.Context, reason string) {
+	count(ctx, "fallbacks", "reason", reason)
+}
+
+func count(ctx context.Context, name, key, value string) {
+	if counter, err := otel.Meter(scope).Int64Counter("iceberg.scan.planning." + name); err == nil {
+		counter.Add(ctx, 1, metric.WithAttributes(attribute.String("iceberg.scan.planning."+key, value)))
+	}
+}
diff --git a/internal/scanmetrics/telemetry_test.go b/internal/scanmetrics/telemetry_test.go
new file mode 100644
index 000000000..35513a494
--- /dev/null
+++ b/internal/scanmetrics/telemetry_test.go
@@ -0,0 +1,96 @@
+// 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 scanmetrics
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/codes"
+	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+	"go.opentelemetry.io/otel/sdk/metric/metricdata"
+	sdktrace "go.opentelemetry.io/otel/sdk/trace"
+	"go.opentelemetry.io/otel/sdk/trace/tracetest"
+)
+
+func TestPlanningTelemetry(t *testing.T) {
+	// Provider replacement is process-wide; Setenv also prevents t.Parallel.
+	t.Setenv("OTEL_TRACES_SAMPLER", "always_on")
+	reader := sdkmetric.NewManualReader()
+	meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
+	recorder := tracetest.NewSpanRecorder()
+	tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
+	oldMeter, oldTracer := otel.GetMeterProvider(), otel.GetTracerProvider()
+	otel.SetMeterProvider(meter)
+	otel.SetTracerProvider(tracer)
+	t.Cleanup(func() {
+		otel.SetMeterProvider(oldMeter)
+		otel.SetTracerProvider(oldTracer)
+		require.NoError(t, meter.Shutdown(context.Background()))
+		require.NoError(t, tracer.Shutdown(context.Background()))
+	})
+
+	ctx, finish := Start(t.Context(), "plan")
+	Retry(ctx, "plan")
+	finish(nil)
+	ctx, finish = Start(t.Context(), "fetch-result")
+	Expired(ctx, "fetch-result")
+	finish(errors.New("secret token and sensitive filter value"))
+	Fallback(t.Context(), "capability")
+
+	spans := recorder.Ended()
+	require.Len(t, spans, 2)
+	assert.Equal(t, "iceberg.scan.planning.plan", spans[0].Name())
+	assert.Equal(t, codes.Unset, spans[0].Status().Code)
+	assert.Equal(t, codes.Error, spans[1].Status().Code)
+	assert.Equal(t, "scan planning failed", spans[1].Status().Description)
+	assert.Empty(t, spans[1].Events(), "raw errors must not be exported")
+	require.Len(t, spans[1].Attributes(), 1)
+	assert.Equal(t, "fetch-result", spans[1].Attributes()[0].Value.AsString())
+
+	var resource metricdata.ResourceMetrics
+	require.NoError(t, reader.Collect(t.Context(), &resource))
+	counts := make(map[string]int64)
+	durationCount := uint64(0)
+	for _, scope := range resource.ScopeMetrics {
+		for _, m := range scope.Metrics {
+			switch data := m.Data.(type) {
+			case metricdata.Sum[int64]:
+				for _, point := range data.DataPoints {
+					counts[m.Name] += point.Value
+				}
+			case metricdata.Histogram[float64]:
+				assert.Equal(t, "ms", m.Unit)
+				for _, point := range data.DataPoints {
+					durationCount += point.Count
+				}
+			}
+		}
+	}
+	assert.Equal(t, map[string]int64{
+		"iceberg.scan.planning.requests":    2,
+		"iceberg.scan.planning.retries":     1,
+		"iceberg.scan.planning.expirations": 1,
+		"iceberg.scan.planning.fallbacks":   1,
+	}, counts)
+	assert.Equal(t, uint64(2), durationCount)
+}
diff --git a/table/scan_splits_test.go b/table/scan_splits_test.go
index 6d3e08024..b7d9937a9 100644
--- a/table/scan_splits_test.go
+++ b/table/scan_splits_test.go
@@ -237,6 +237,25 @@ func TestSplitParquetScanTaskKeepsUnsafeTasksIntact(t *testing.T) {
 	}
 }
 
+func TestSplitRemoteScanTasksPreservesPartialTasks(t *testing.T) {
+	t.Parallel()
+	file := splitTestDataFile(t, iceberg.ParquetFile, 100, []int64{8, 40, 70})
+	partial := []FileScanTask{
+		{File: file, Start: 0, Length: 40, Residual: iceberg.AlwaysTrue{}},
+		{File: file, Start: 40, Length: 60, Residual: iceberg.AlwaysFalse{}},
+	}
+	got := splitRemoteScanTasks(partial, 20)
+	assert.Equal(t, partial, got)
+	assert.Same(t, &partial[0], &got[0], "pass-through should retain the original slice")
+
+	// Partial tasks must also survive when another file causes a new slice.
+	mixed := []FileScanTask{partial[0], {File: file, Start: 0, Length: 100}, partial[1]}
+	got = splitRemoteScanTasks(mixed, 20)
+	require.Len(t, got, 5)
+	assert.Equal(t, partial[0], got[0])
+	assert.Equal(t, partial[1], got[len(got)-1])
+}
+
 func TestAdjustParquetTaskRangeToPhysicalFileSize(t *testing.T) {
 	file := splitTestDataFile(t, iceberg.ParquetFile, 100, []int64{8, 40, 70})
 	tests := []struct {
diff --git a/table/scanner.go b/table/scanner.go
index 713867b5e..e86c811b4 100644
--- a/table/scanner.go
+++ b/table/scanner.go
@@ -34,6 +34,7 @@ import (
 	"github.com/apache/arrow-go/v18/arrow/array"
 	"github.com/apache/iceberg-go"
 	iceberginternal "github.com/apache/iceberg-go/internal"
+	"github.com/apache/iceberg-go/internal/scanmetrics"
 	"github.com/apache/iceberg-go/io"
 	"github.com/apache/iceberg-go/metrics"
 	"golang.org/x/sync/errgroup"
@@ -1630,8 +1631,11 @@ func (scan *Scan) planFiles(ctx context.Context, projectScanColumns bool) ([]Fil
 	case ScanPlanningRemote:
 		return scan.planFilesRemote(ctx)
 	case ScanPlanningAuto:
-		if supportsAutomaticRemotePlanning(scan.planner) &&
-			!scan.requiresLastUpdatedSequenceNumber() {
+		if !supportsAutomaticRemotePlanning(scan.planner) {
+			scanmetrics.Fallback(ctx, "capability")
+		} else if scan.requiresLastUpdatedSequenceNumber() {
+			scanmetrics.Fallback(ctx, "row-lineage")
+		} else {
 			return scan.planFilesRemote(ctx)
 		}
 	case ScanPlanningLocal: