Summary
The REST code generator emits http.NewRequest("GET", ...) without httpReq.WithContext(ctx) for the paginated iterator branch. As a result, every paginated List / AggregatedList call in generated REST clients (e.g., cloud.google.com/go/compute/apiv1, cloud.google.com/go/asset/apiv1, cloud.google.com/go/run/apiv2) sends HTTP requests whose req.Context() is context.Background().
This breaks OpenTelemetry trace propagation: otelhttp.Transport (which is auto-installed by google.golang.org/api/transport/http) sees no parent span in req.Context() and emits a brand-new root span for every paginated GET, even when the caller passes a ctx containing an active span. Non-paginated GETs from the same client work correctly.
Where
internal/gengapic/genrest.go around line 875 — the generator already has an unresolved self-comment:
// TODO: Should this http.Request use WithContext?
httpReq, err := http.NewRequest("GET", baseUrl.String(), nil)
The other four HTTP emission sites in the same file (lines 703, 988, 1084, 1182 — non-paginated GET, POST, PUT, DELETE) all correctly emit httpReq = httpReq.WithContext(ctx). Only the paginated GET branch is missing it.
Impact
Confirmed on main for at least:
compute/apiv1/network_endpoint_groups_client.go (AggregatedList, List)
asset/apiv1/asset_client.go (ListAssets, SearchAllResources, ...)
run/apiv2/services_client.go (ListServices, ListRevisions, ...)
All paginated REST List / AggregatedList methods across gapic-generated clients are affected.
Reproduction
Minimal reproduction (single main.go) that uses an in-memory span recorder and a fake HTTP server to isolate the bug from real GCP credentials. Saves the generated client behavior side-by-side for paginated AggregatedList vs non-paginated Get.
go.mod:
module repro
go 1.22
require (
cloud.google.com/go/compute v1.24.0
go.opentelemetry.io/otel v1.22.0
go.opentelemetry.io/otel/sdk v1.22.0
go.opentelemetry.io/otel/trace v1.22.0
google.golang.org/api v0.162.0
)
main.go:
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
compute "cloud.google.com/go/compute/apiv1"
"cloud.google.com/go/compute/apiv1/computepb"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
)
func main() {
sr := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
otel.SetTracerProvider(tp)
tracer := tp.Tracer("repro")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "/aggregated/") {
_ = json.NewEncoder(w).Encode(map[string]any{"items": map[string]any{}})
} else {
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}})
}
}))
defer srv.Close()
ctx := context.Background()
// (1) Paginated GET: AggregatedList
negClient, _ := compute.NewNetworkEndpointGroupsRESTClient(ctx,
option.WithEndpoint(srv.URL), option.WithoutAuthentication())
defer negClient.Close()
ctx1, parent1 := tracer.Start(ctx, "parent-paginated-aggregatedlist")
it := negClient.AggregatedList(ctx1, &computepb.AggregatedListNetworkEndpointGroupsRequest{Project: "fake"})
for {
if _, err := it.Next(); err == iterator.Done {
break
} else if err != nil {
break
}
}
parent1.End()
// (2) Non-paginated GET: Get
regClient, _ := compute.NewRegionsRESTClient(ctx,
option.WithEndpoint(srv.URL), option.WithoutAuthentication())
defer regClient.Close()
ctx2, parent2 := tracer.Start(ctx, "parent-nonpaginated-get")
_, _ = regClient.Get(ctx2, &computepb.GetRegionRequest{Project: "fake", Region: "us-central1"})
parent2.End()
fmt.Printf("PARENT1 span_id=%s trace_id=%s\n", parent1.SpanContext().SpanID(), parent1.SpanContext().TraceID())
fmt.Printf("PARENT2 span_id=%s trace_id=%s\n", parent2.SpanContext().SpanID(), parent2.SpanContext().TraceID())
for _, s := range sr.Ended() {
fmt.Printf("name=%-40s span_id=%s parent=%s trace=%s lib=%s\n",
s.Name(), s.SpanContext().SpanID(), s.Parent().SpanID(),
s.SpanContext().TraceID(), s.InstrumentationLibrary().Name)
}
}
Observed output
PARENT1 span_id=fcb5171cdd5e7b72 trace_id=ee0b190ca6a887bba8bab5a9f5251a02
PARENT2 span_id=42bfb57582dfc82e trace_id=f3d78df9671580ecebbe93e9ca1bb807
name=HTTP GET span_id=5498cfb343917bd9 parent=0000000000000000 trace=4d82ad7dd31e5199006753e5c41cb11b lib=go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
name=parent-paginated-aggregatedlist span_id=fcb5171cdd5e7b72 parent=0000000000000000 trace=ee0b190ca6a887bba8bab5a9f5251a02 lib=repro
name=HTTP GET span_id=241c70b17b9b47d2 parent=42bfb57582dfc82e trace=f3d78df9671580ecebbe93e9ca1bb807 lib=go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
name=parent-nonpaginated-get span_id=42bfb57582dfc82e parent=0000000000000000 trace=f3d78df9671580ecebbe93e9ca1bb807 lib=repro
Interpretation
| Call |
otelhttp span parent |
otelhttp span trace_id |
AggregatedList (paginated GET) |
0 (orphan / root) |
Different trace from PARENT1 |
Get (non-paginated GET) |
42bfb575... = PARENT2 span_id |
Same trace as PARENT2 |
The paginated GET produces a brand-new root span on a brand-new trace, instead of becoming a child of parent-paginated-aggregatedlist. The non-paginated Get on the same client behaves correctly. This isolates the bug to the paginated-GET code path emitted by genrest.go:875.
Suggested fix
Emit httpReq = httpReq.WithContext(ctx) in the paginated GET branch of genrest.go, matching the other four emission sites. Then regenerate the affected clients.
Notes
This is also the only difference in behavior between cloud.google.com/go/*/apiv* (gax-based generated) clients and the legacy google.golang.org/api/<service>/v1 Discovery clients (which expose .Context(ctx).Do() and propagate correctly).
Summary
The REST code generator emits
http.NewRequest("GET", ...)withouthttpReq.WithContext(ctx)for the paginated iterator branch. As a result, every paginatedList/AggregatedListcall in generated REST clients (e.g.,cloud.google.com/go/compute/apiv1,cloud.google.com/go/asset/apiv1,cloud.google.com/go/run/apiv2) sends HTTP requests whosereq.Context()iscontext.Background().This breaks OpenTelemetry trace propagation:
otelhttp.Transport(which is auto-installed bygoogle.golang.org/api/transport/http) sees no parent span inreq.Context()and emits a brand-new root span for every paginated GET, even when the caller passes actxcontaining an active span. Non-paginated GETs from the same client work correctly.Where
internal/gengapic/genrest.goaround line 875 — the generator already has an unresolved self-comment:The other four HTTP emission sites in the same file (lines 703, 988, 1084, 1182 — non-paginated GET, POST, PUT, DELETE) all correctly emit
httpReq = httpReq.WithContext(ctx). Only the paginated GET branch is missing it.Impact
Confirmed on
mainfor at least:compute/apiv1/network_endpoint_groups_client.go(AggregatedList,List)asset/apiv1/asset_client.go(ListAssets,SearchAllResources, ...)run/apiv2/services_client.go(ListServices,ListRevisions, ...)All paginated REST
List/AggregatedListmethods across gapic-generated clients are affected.Reproduction
Minimal reproduction (single
main.go) that uses an in-memory span recorder and a fake HTTP server to isolate the bug from real GCP credentials. Saves the generated client behavior side-by-side for paginatedAggregatedListvs non-paginatedGet.go.mod:main.go:Observed output
Interpretation
AggregatedList(paginated GET)0(orphan / root)Get(non-paginated GET)42bfb575...= PARENT2 span_idThe paginated GET produces a brand-new root span on a brand-new trace, instead of becoming a child of
parent-paginated-aggregatedlist. The non-paginatedGeton the same client behaves correctly. This isolates the bug to the paginated-GET code path emitted bygenrest.go:875.Suggested fix
Emit
httpReq = httpReq.WithContext(ctx)in the paginated GET branch ofgenrest.go, matching the other four emission sites. Then regenerate the affected clients.Notes
This is also the only difference in behavior between
cloud.google.com/go/*/apiv*(gax-based generated) clients and the legacygoogle.golang.org/api/<service>/v1Discovery clients (which expose.Context(ctx).Do()and propagate correctly).