Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion bigtable/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ type Client struct {
// are lazily materialized on first use, so an idle client pays
// only for one channel pool and one config-poll goroutine.
sessionImpl session.Client
// tcpStats is populated when ClientConfig.EnableDebug is true. Nil
// otherwise. Client.TCPStats() returns this directly; callers hand
// it to bigtable/debugview.Handler for the tcpz page.
tcpStats *TCPStats
// sessionTables caches per-resource session.TableAPI handles so
// repeat Open* calls return the same handle (and by extension the
// same underlying session pools). session.Client does not cache;
Expand Down Expand Up @@ -100,6 +104,21 @@ type ClientConfig struct {

// DisableDirectAccess disables direct access by default.
DisableDirectAccess bool

// EnableDebug opts the client into the /debug/{sessionz,afez,loadz,
// channelz,configz,tcpz,debugtagsz} pages served by
// bigtable/debugview.Handler.
//
// When true, NewClientWithConfig auto-constructs the internal
// TCPStats collector and attaches its dial option so per-connection
// TCP_INFO scraping is available via Client.TCPStats(). The session-,
// channel-, and config-debug providers are unconditionally reachable
// via Client.SessionDebug / ChannelDebug / ConfigDebug regardless of
// this flag; EnableDebug is purely about opting into the extra
// dial-time interception TCPStats needs.
//
// Zero cost when false — no TCPStats allocation, no dial hook.
EnableDebug bool
}

// MetricsProvider is a wrapper for the built-in metrics meter provider.
Expand Down Expand Up @@ -172,6 +191,17 @@ func NewClientWithConfig(ctx context.Context, project, instance string, config C
// Allow non-default service account in DirectPath.
o = append(o, internaloption.AllowNonDefaultServiceAccount(true))
o = append(o, opts...)
// When EnableDebug is set, construct the TCPStats collector and
// append its dial option so every subsequent gRPC dial (both the
// classic channel pool and, via option propagation, the session
// channel pool) registers with the collector. Stashed on Client
// so callers can retrieve it via Client.TCPStats() and hand it to
// bigtable/debugview.Handler.
var tcpStats *TCPStats
if config.EnableDebug {
tcpStats = NewTCPStats()
o = append(o, tcpStats.ClientOption())
}
o = append(o, internaloption.EnableNewAuthLibrary())
o = append(o, internaloption.EnableJwtWithScope())

Expand Down Expand Up @@ -243,6 +273,7 @@ func NewClientWithConfig(ctx context.Context, project, instance string, config C
featureFlagsMD: directAccessMD,
mPool: mPool,
diverter: btransport.NewDiverter(0.0),
tcpStats: tcpStats,
}

// Session data-plane backend construction has two guardrails so it
Expand Down Expand Up @@ -285,7 +316,7 @@ func NewClientWithConfig(ctx context.Context, project, instance string, config C
// in (endpoint, scopes, user-agent, interceptors) — passing
// bare opts leaves the resolver target empty and the dial
// aborts with "passthrough: received empty target in Build()".
sc, sessionErr := session.NewClient(ctx, project, instance, config.AppProfile, metricsProvider, featureFlagsProto, o...)
sc, sessionErr := session.NewClient(ctx, project, instance, config.AppProfile, metricsProvider, featureFlagsProto, config.EnableDebug, o...)
if sessionErr != nil {
// Best-effort cleanup of the classic pool since we won't
// return c to the caller. Go through the ManagedChannelPool
Expand Down
205 changes: 205 additions & 0 deletions bigtable/debugview/afez.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Copyright 2026 Google LLC
//
// Licensed 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.

// afez view — live per-AFE bucketing of every session pool. Groups AFE
// rows by pool: id, ref-count (idle+in-use), idle, in-use, transport
// EWMA, e2e EWMA, last-connected age. AFEs with ref-count == 0 (empty
// buckets awaiting GC) are faded.

package debugview

import (
"fmt"
"html/template"
"net/http"
"time"

"cloud.google.com/go/bigtable"
btransport "cloud.google.com/go/bigtable/internal/transport"
)

// newAfezHandler wires the afez sub-mux for a given SessionDebugProvider.
// Called from Handler; the returned handler still owns its own inner mux
// so /?format=json and other query routing land on the same "/" handler.
func newAfezHandler(p bigtable.SessionDebugProvider) http.Handler {
mux := http.NewServeMux()
srv := &afezServer{provider: p}
mux.HandleFunc("/", srv.handleIndex)
return mux
}

type afezServer struct {
provider bigtable.SessionDebugProvider
}

// afezRow is one AFE — the JSON-friendly representation the HTML template
// renders and the ?format=json response emits verbatim. JSON field names
// are stable across the fold.
type afezRow struct {
Pool string `json:"pool"`
AfeID int64 `json:"afeId"`
AfeIDHex string `json:"afeIdHex"`
RefCount int `json:"refCount"`
IdleCount int `json:"idleCount"`
InUseCount int `json:"inUseCount"`
TransportEwma time.Duration `json:"transportEwmaNanos"`
E2eEwma time.Duration `json:"e2eEwmaNanos"`
LastConnected time.Time `json:"lastConnected"`
IdleAge time.Duration `json:"idleAgeNanos"`
PendingGC bool `json:"pendingGC"`
}

type afezPage struct {
Generated time.Time
Pools []afezPoolBlock
Total int
}

type afezPoolBlock struct {
Name string
Rows []afezRow
}

func (s *afezServer) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "" {
http.NotFound(w, r)
return
}
now := time.Now()
pools, total := s.collect(now)

if r.URL.Query().Get("format") == "json" {
var flat []afezRow
for _, pb := range pools {
flat = append(flat, pb.Rows...)
}
writeJSON(w, struct {
CapturedAt time.Time `json:"capturedAt"`
AFEs []afezRow `json:"afes"`
Total int `json:"total"`
}{now, flat, total})
return
}

writeHTML(w, afezTpl, afezPage{Generated: now, Pools: pools, Total: total})
}

// collect walks every pool snapshot and returns per-pool rows sorted by
// AFE id (sessionList.Snapshot already sorts).
func (s *afezServer) collect(now time.Time) (pools []afezPoolBlock, total int) {
if s.provider == nil {
return nil, 0
}
for _, p := range s.provider.Snapshot() {
if len(p.AFEs) == 0 {
continue
}
rows := make([]afezRow, 0, len(p.AFEs))
for _, a := range p.AFEs {
inUse := a.RefCount - a.IdleCount
if inUse < 0 {
inUse = 0
}
var idleAge time.Duration
if !a.LastConnected.IsZero() {
idleAge = now.Sub(a.LastConnected)
}
rows = append(rows, afezRow{
Pool: p.Name,
AfeID: int64(a.ID),
AfeIDHex: fmt.Sprintf("%x", uint64(a.ID)),
RefCount: a.RefCount,
IdleCount: a.IdleCount,
InUseCount: inUse,
TransportEwma: a.TransportEwma,
E2eEwma: a.E2eEwma,
LastConnected: a.LastConnected,
IdleAge: idleAge,
PendingGC: a.RefCount == 0,
})
total++
}
pools = append(pools, afezPoolBlock{Name: p.Name, Rows: rows})
}
return pools, total
}

var _ = btransport.AfeSnapshotRow{} // guard against accidental unused-import trim

func afezFuncs() template.FuncMap {
m := commonFuncs()
m["dur"] = func(d time.Duration) string {
if d == 0 {
return "—"
}
return roundDurationShort(d).String()
}
m["age"] = func(d time.Duration) string {
if d <= 0 {
return "—"
}
return roundDurationShort(d).String()
}
return m
}

var afezTpl = template.Must(template.New("afez").Funcs(afezFuncs()).Parse(afezTplSrc))

const afezTplSrc = `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="5">
<title>afez</title>
<style>
body { font-family: -apple-system,Segoe UI,Roboto,sans-serif; margin: 1rem; }
h1 { font-size: 1.1rem; margin: 0 0 .5rem; }
h2 { font-size: .95rem; margin: 1.25rem 0 .25rem; color: #444; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { padding: .3rem .55rem; text-align: right; border-bottom: 1px solid #eee; font-variant-numeric: tabular-nums; }
th:first-child, td:first-child { text-align: left; }
th { background: #f6f6f6; font-weight: 600; }
tr.pending-gc td { color: #999; font-style: italic; }
.empty { color: #888; margin: 1rem 0; }
.gen { color: #888; font-size: .8rem; margin-top: 1rem; }
</style>
</head><body>
<h1>Bigtable AFE view — {{.Total}} AFE(s) across {{len .Pools}} pool(s)</h1>
{{if .Pools}}
{{range .Pools}}
<h2>{{.Name}}</h2>
<table>
<thead><tr>
<th>AFE id</th><th>ref</th><th>idle</th><th>in-use</th>
<th>transport EWMA</th><th>e2e EWMA</th><th>last connected</th>
</tr></thead>
<tbody>
{{range .Rows}}
<tr{{if .PendingGC}} class="pending-gc"{{end}}>
<td>{{if .AfeID}}0x{{.AfeIDHex}}{{else}}<em>unknown</em>{{end}}</td>
<td>{{.RefCount}}</td>
<td>{{.IdleCount}}</td>
<td>{{.InUseCount}}</td>
<td>{{dur .TransportEwma}}</td>
<td>{{dur .E2eEwma}}</td>
<td>{{age .IdleAge}} ago</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{else}}
<p class="empty">No AFE buckets recorded yet. Sessions may still be handshaking, or session pooling may be disabled.</p>
{{end}}
<p class="gen">Generated {{.Generated.Format "15:04:05.000 MST"}} — auto-refresh 5s.</p>
</body></html>`
113 changes: 113 additions & 0 deletions bigtable/debugview/afez_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2026 Google LLC
//
// Licensed 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 debugview

import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"

btransport "cloud.google.com/go/bigtable/internal/transport"
)

func afezSampleSnapshot() []btransport.PoolSnapshot {
now := time.Now()
// Provider returns rows in whatever order it chooses. Production
// sessionList.Snapshot sorts by ID; this fake mirrors that (0
// first, 0xa1b2c3 next) so the test asserts against a
// deterministic order.
return []btransport.PoolSnapshot{
{
Name: "my-table:read",
AFEs: []btransport.AfeSnapshotRow{
{
ID: 0,
RefCount: 0, // pending-GC
IdleCount: 0,
LastConnected: now.Add(-11 * time.Minute),
},
{
ID: 0xa1b2c3,
RefCount: 3,
IdleCount: 2,
TransportEwma: 500 * time.Microsecond,
E2eEwma: 4 * time.Millisecond,
LastConnected: now.Add(-30 * time.Second),
},
},
},
}
}

func TestAfez_Index_HTML_RendersAFEs(t *testing.T) {
h := newAfezHandler(fakeSessionProvider{pools: afezSampleSnapshot()})
rec := get(t, h, "/")

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"my-table:read", "0xa1b2c3", "Bigtable AFE view", "pending-gc"} {
if !strings.Contains(body, want) {
t.Errorf("index missing %q", want)
}
}
if !strings.Contains(body, "unknown") {
t.Errorf("expected AFE id=0 rendered as 'unknown', body: %s", body)
}
}

func TestAfez_Index_JSON_RoundTrips(t *testing.T) {
h := newAfezHandler(fakeSessionProvider{pools: afezSampleSnapshot()})
rec := get(t, h, "/?format=json")

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got struct {
AFEs []afezRow `json:"afes"`
Total int `json:"total"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if got.Total != 2 {
t.Errorf("total = %d, want 2", got.Total)
}
if len(got.AFEs) != 2 {
t.Fatalf("len(afes) = %d, want 2", len(got.AFEs))
}
// Sorted by ID ascending → 0 first, then 0xa1b2c3.
if got.AFEs[0].AfeID != 0 || !got.AFEs[0].PendingGC {
t.Errorf("afes[0] = %+v, want id=0 pending-gc", got.AFEs[0])
}
if got.AFEs[1].AfeID != 0xa1b2c3 || got.AFEs[1].PendingGC {
t.Errorf("afes[1] = %+v, want id=0xa1b2c3 active", got.AFEs[1])
}
}

func TestAfez_Index_NilProvider(t *testing.T) {
h := newAfezHandler(nil)
rec := get(t, h, "/")

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "No AFE buckets recorded yet") {
t.Errorf("expected empty-state message, got: %s", rec.Body.String())
}
}
Loading
Loading