Skip to content

genrest: paginated GET requests omit WithContext(ctx), breaking trace propagation in generated REST clients #1749

@sachaos

Description

@sachaos

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions