zeroshade commented on code in PR #1655:
URL: https://github.com/apache/iceberg-go/pull/1655#discussion_r3732117056


##########
catalog/rest/rest.go:
##########
@@ -1109,6 +1109,15 @@ func (r *Catalog) tableFromResponse(_ context.Context, 
identifier []string, meta
        if err != nil {
                return nil, fmt.Errorf("failed to initialize metrics reporter: 
%w", err)
        }
+       // Opt-in: POST scan/commit reports to the catalog's metrics endpoint, 
but
+       // only when enabled and the server advertises the endpoint.
+       if config.GetBool(keyReportMetricsEnabled, false) && 
r.endpoints.check(endpointReportMetrics) == nil {

Review Comment:
   `config` here is the **merged** table and catalog configuration: the 
server's `defaults`, the server's `overrides`, and the properties on the table 
response all feed into it. So this condition is satisfied whenever a *server* 
sets `rest.metrics-reporting-enabled=true`, and this client will start POSTing 
scan and commit telemetry to that server without the user having opted in.
   
   That is the inverse of what the PR description and issue #1236 describe. The 
constant's own doc comment says reporting is "disabled by default so existing 
users see no new network traffic unless they turn it on" — but "they" here can 
be the server. A default that a remote party can flip is not a default.
   
   The comment immediately above this block already articulates the principle: 
server-vended reporter selection "should be an explicit decision rather than a 
side effect of merge order." Turning on network telemetry is a strictly larger 
decision than choosing between reporter implementations, so it should be held 
to at least the same standard.
   
   Suggested fix: read enablement only from client-originated configuration — 
`r.reporterProps`, as line 1108 already does for reporter selection, or a 
dedicated explicit catalog option — and never from the merged `config`. Then 
pin the precedence with a test: server `defaults`, server `overrides`, and 
table-response properties each setting the key to `true` must leave reporting 
off when the client has not enabled it.



##########
catalog/rest/metrics_reporter.go:
##########
@@ -0,0 +1,76 @@
+// 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"
+       "log/slog"
+       "net/http"
+       "net/url"
+
+       "github.com/apache/iceberg-go/metrics"
+)
+
+// keyReportMetricsEnabled opts a REST catalog into POSTing scan/commit reports
+// to the catalog's metrics endpoint. It is disabled by default so existing
+// users see no new network traffic unless they turn it on.
+const keyReportMetricsEnabled = "rest.metrics-reporting-enabled"
+
+// restMetricsReporter POSTs metrics reports to a table's REST metrics endpoint
+// (POST .../tables/{table}/metrics). It is bound to a single table's path and
+// satisfies metrics.Reporter.
+type restMetricsReporter struct {
+       baseURI *url.URL
+       cl      *http.Client
+       path    []string // table metrics path, relative to baseURI
+}
+
+var _ metrics.Reporter = (*restMetricsReporter)(nil)
+
+// Report wraps the report in a ReportMetricsRequest and POSTs it on a
+// background goroutine. Per the Reporter contract it never blocks or fails the
+// observed scan/commit: the send is detached from the caller's cancellation,
+// and any error is logged and swallowed.
+func (rep *restMetricsReporter) Report(ctx context.Context, report 
metrics.MetricsReport) {
+       if report == nil {
+               return
+       }
+
+       req := metrics.NewReportMetricsRequest(report)
+       // Detach from the caller's cancellation (the scan/commit is already 
done)
+       // while preserving any context values used by the HTTP client.
+       sendCtx := context.WithoutCancel(ctx)
+
+       go func() {
+               defer func() {
+                       if r := recover(); r != nil {
+                               slog.Default().Warn("iceberg: panic while 
reporting metrics to REST catalog", "recovered", r)
+                       }
+               }()
+
+               if _, err := doPost[metrics.ReportMetricsRequest, struct{}](
+                       sendCtx, rep.baseURI, rep.path, req, rep.cl, nil, 
allowNoContent()); err != nil {
+                       slog.Default().Warn("iceberg: failed to report metrics 
to REST catalog", "error", err)
+               }
+       }()
+}
+
+// Close satisfies [metrics.Reporter]. The reporter is stateless — it borrows
+// the catalog's shared HTTP client rather than owning one — so there is 
nothing
+// to release.
+func (rep *restMetricsReporter) Close() error { return nil }

Review Comment:
   The comment is accurate that the reporter borrows the catalog's HTTP client 
rather than owning one — but as of this PR it does own goroutines, and a no-op 
`Close` cannot reclaim them. In-flight reports outlive the catalog with nothing 
to cancel them and nothing to wait on, so a caller that closes the catalog and 
expects its outbound network activity to stop does not get that.
   
   Suggested fix: once reporting is backed by a catalog-owned worker pool, have 
`Close` cancel the pool's context and wait for the workers to return, bounded 
by the reporting timeout so shutdown cannot hang either. That makes shutdown 
observable and gives the drain path something to test.



##########
catalog/rest/metrics_reporter_test.go:
##########
@@ -0,0 +1,148 @@
+// 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 (
+       "bytes"
+       "context"
+       "encoding/json"
+       "io"
+       "net/http"
+       "net/url"
+       "testing"
+       "time"
+
+       "github.com/apache/iceberg-go/metrics"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+type capturedRequest struct {
+       method string
+       path   string
+       body   []byte
+}
+
+// captureTransport records the request it receives and returns 204 No Content,
+// avoiding any real network listener.
+type captureTransport struct {
+       ch    chan capturedRequest
+       block <-chan struct{} // if non-nil, RoundTrip waits on it before 
responding
+       err   error           // if non-nil, returned instead of a response
+}
+
+func (c *captureTransport) RoundTrip(r *http.Request) (*http.Response, error) {
+       body, _ := io.ReadAll(r.Body)
+       if c.ch != nil {
+               c.ch <- capturedRequest{method: r.Method, path: r.URL.Path, 
body: body}
+       }
+       if c.block != nil {
+               <-c.block
+       }
+       if c.err != nil {
+               return nil, c.err
+       }
+
+       return &http.Response{
+               StatusCode: http.StatusNoContent,
+               Body:       io.NopCloser(bytes.NewReader(nil)),
+               Header:     make(http.Header),
+       }, nil
+}
+
+func newTestReporter(t *testing.T, tr *captureTransport) *restMetricsReporter {

Review Comment:
   Non-blocking. This helper hands the reporter a pre-built relative path, so 
the assertion at line 89 only ever checks `/namespaces/db/tables/t/metrics`. 
The parts most likely to break in practice are exactly the parts skipped: 
`reqPath` composing the full `/v1/{prefix}/...` URL, multi-level namespaces, 
and identifier escaping.
   
   Suggested fix: add a test that goes through `tableFromResponse` — or at 
minimum `endpointReportMetrics.reqPath` — with a configured prefix and a nested 
namespace containing a character that requires escaping, and assert the 
complete request path.



##########
catalog/rest/metrics_reporter.go:
##########
@@ -0,0 +1,76 @@
+// 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"
+       "log/slog"
+       "net/http"
+       "net/url"
+
+       "github.com/apache/iceberg-go/metrics"
+)
+
+// keyReportMetricsEnabled opts a REST catalog into POSTing scan/commit reports
+// to the catalog's metrics endpoint. It is disabled by default so existing
+// users see no new network traffic unless they turn it on.
+const keyReportMetricsEnabled = "rest.metrics-reporting-enabled"

Review Comment:
   The property name here is `rest.metrics-reporting-enabled`, with a dot after 
`rest`. Iceberg Java and the Iceberg documentation both use 
`rest-metrics-reporting-enabled`, with a hyphen.
   
   That one character makes this feature unreachable for anyone configuring it 
the documented, cross-implementation way: they set the canonical key, this code 
never sees it, and reporting silently stays off with no error and no warning to 
explain why. Silent no-ops on a configuration key are particularly expensive to 
debug, since the natural conclusion is that the feature is broken rather than 
misnamed.
   
   Suggested fix: make `rest-metrics-reporting-enabled` the canonical key. If 
the dotted spelling is worth keeping, accept it as a deliberate alias rather 
than as the only accepted name. It would also help to export the constant, or 
expose a typed catalog option, so callers are not hardcoding the string at all.



##########
catalog/rest/metrics_reporter.go:
##########
@@ -0,0 +1,76 @@
+// 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"
+       "log/slog"
+       "net/http"
+       "net/url"
+
+       "github.com/apache/iceberg-go/metrics"
+)
+
+// keyReportMetricsEnabled opts a REST catalog into POSTing scan/commit reports
+// to the catalog's metrics endpoint. It is disabled by default so existing
+// users see no new network traffic unless they turn it on.
+const keyReportMetricsEnabled = "rest.metrics-reporting-enabled"
+
+// restMetricsReporter POSTs metrics reports to a table's REST metrics endpoint
+// (POST .../tables/{table}/metrics). It is bound to a single table's path and
+// satisfies metrics.Reporter.
+type restMetricsReporter struct {
+       baseURI *url.URL
+       cl      *http.Client
+       path    []string // table metrics path, relative to baseURI
+}
+
+var _ metrics.Reporter = (*restMetricsReporter)(nil)
+
+// Report wraps the report in a ReportMetricsRequest and POSTs it on a
+// background goroutine. Per the Reporter contract it never blocks or fails the
+// observed scan/commit: the send is detached from the caller's cancellation,
+// and any error is logged and swallowed.
+func (rep *restMetricsReporter) Report(ctx context.Context, report 
metrics.MetricsReport) {
+       if report == nil {
+               return
+       }
+
+       req := metrics.NewReportMetricsRequest(report)
+       // Detach from the caller's cancellation (the scan/commit is already 
done)
+       // while preserving any context values used by the HTTP client.
+       sendCtx := context.WithoutCancel(ctx)
+
+       go func() {

Review Comment:
   Every call to `Report` spawns a goroutine that nothing tracks and nothing 
bounds. Paired with the missing timeout above, a metrics endpoint that accepts 
connections but never responds accumulates one permanently blocked goroutine — 
and one held connection — per scan and per commit, for as long as the process 
runs.
   
   That is where the isolation guarantee stops holding. Any individual report 
is harmless, but the unbounded accumulation can exhaust connections and 
eventually degrade the very scans and commits this reporter is meant to stay 
out of the way of.
   
   Suggested fix: move dispatch to a catalog-owned bounded queue served by a 
small fixed worker pool, rather than spawning per report. When the queue is 
full, drop the report and log the drop — shedding telemetry is the correct 
behavior here, and logging it makes back-pressure visible instead of silent. 
Tracking the workers also gives `Close` something to drain (see below).



##########
catalog/rest/metrics_reporter.go:
##########
@@ -0,0 +1,76 @@
+// 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"
+       "log/slog"
+       "net/http"
+       "net/url"
+
+       "github.com/apache/iceberg-go/metrics"
+)
+
+// keyReportMetricsEnabled opts a REST catalog into POSTing scan/commit reports
+// to the catalog's metrics endpoint. It is disabled by default so existing
+// users see no new network traffic unless they turn it on.
+const keyReportMetricsEnabled = "rest.metrics-reporting-enabled"
+
+// restMetricsReporter POSTs metrics reports to a table's REST metrics endpoint
+// (POST .../tables/{table}/metrics). It is bound to a single table's path and
+// satisfies metrics.Reporter.
+type restMetricsReporter struct {
+       baseURI *url.URL
+       cl      *http.Client
+       path    []string // table metrics path, relative to baseURI
+}
+
+var _ metrics.Reporter = (*restMetricsReporter)(nil)
+
+// Report wraps the report in a ReportMetricsRequest and POSTs it on a
+// background goroutine. Per the Reporter contract it never blocks or fails the
+// observed scan/commit: the send is detached from the caller's cancellation,
+// and any error is logged and swallowed.
+func (rep *restMetricsReporter) Report(ctx context.Context, report 
metrics.MetricsReport) {
+       if report == nil {
+               return
+       }
+
+       req := metrics.NewReportMetricsRequest(report)
+       // Detach from the caller's cancellation (the scan/commit is already 
done)
+       // while preserving any context values used by the HTTP client.
+       sendCtx := context.WithoutCancel(ctx)

Review Comment:
   Detaching from the caller is the right instinct — the scan or commit has 
already finished and its cancellation should not abort the report. The problem 
is that `context.WithoutCancel` removes the caller's deadline without 
installing one of its own, and the catalog's shared `http.Client` has no 
`Timeout` set either. This request therefore has no bound at any layer.
   
   Concretely, that means an unreachable or black-holing endpoint, a token 
refresh that never returns, a wait on response headers, or a response body that 
never finishes draining will block for the lifetime of the process.
   
   Suggested fix: keep the detach, and add a deadline of its own:
   
   ```go
   sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 
reportTimeout)
   ```
   
   with `defer cancel()` inside the goroutine so it covers auth plus the 
complete request and response cycle. A conservative default of a few seconds, 
ideally configurable, seems right for telemetry that is safe to drop.



##########
catalog/rest/metrics_reporter_test.go:
##########
@@ -0,0 +1,148 @@
+// 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 (
+       "bytes"
+       "context"
+       "encoding/json"
+       "io"
+       "net/http"
+       "net/url"
+       "testing"
+       "time"
+
+       "github.com/apache/iceberg-go/metrics"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+type capturedRequest struct {

Review Comment:
   Non-blocking. `capturedRequest` records only the method, path, and body, so 
no test can currently assert anything about headers. Reusing the catalog's 
authenticated client is one of the more valuable properties of this design, and 
right now nothing verifies it.
   
   Suggested fix: capture `r.Header` as well, then assert that the metrics POST 
carries the `Authorization` header — including after a token refresh — along 
with `Content-Type: application/json` and any headers configured on the catalog.



-- 
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]

Reply via email to