Skip to content

feat(bigtable): debugview + tcpz stack, gated on ClientConfig.EnableDebug - #20292

Open
sushanb wants to merge 2 commits into
googleapis:mainfrom
sushanb:feat/bigtable-debugview-only
Open

feat(bigtable): debugview + tcpz stack, gated on ClientConfig.EnableDebug#20292
sushanb wants to merge 2 commits into
googleapis:mainfrom
sushanb:feat/bigtable-debugview-only

Conversation

@sushanb

@sushanb sushanb commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports the 7-page bigtable debug UI + per-connection TCP_INFO collector from a downstream feature branch onto `upstream/main`. Wire the entire tree with a one-liner:

```go
client, _ := bigtable.NewClientWithConfig(ctx, "p", "i",
bigtable.ClientConfig{EnableDebug: true})
http.Handle("/debug/", http.StripPrefix("/debug",
debugview.Handler(client)))
```

This is the debugview-only slice of #20275 — the SessionAwareChannelPool stack (`session_aware_scale_monitor.go`, `ClientConfigurationManager.AddChannelPoolConfigListener`, `BigtableChannelPool.TotalStreamCount`) is intentionally excluded and will land as a follow-up.

Public API additions

  • `ClientConfig.EnableDebug bool` — opts a Client into the TCPStats collector. Zero cost when false: no dial hook, no allocation. When true, `NewClientWithConfig` constructs TCPStats and appends its dial option so every gRPC dial (classic + session pools) registers with the collector.
  • `Client.SessionDebug` / `ChannelDebug` / `ConfigDebug` / `TCPStats` accessors — hand these directly to `debugview.Handler`.
  • Type aliases in `bigtable/session_debug.go` re-export the debug provider interfaces + snapshot types from `internal/transport` so `debugview` depends only on the public `bigtable` package.

New packages / files

Path What
`bigtable/debugview/` 8 z-page HTTP handlers (`sessionz`, `afez`, `loadz`, `channelz`, `configz`, `tcpz`, `debugtagsz`) behind a single `Handler(client)` entry. Every view supports `?format=json`. Nil-safe: panels render a "not enabled" banner when the corresponding accessor returns nil.
`bigtable/internal/transport/conn_registry.go` + `tcp_info_{linux,other}.go` The collector's backing store and OS-specific `TCP_INFO` scrape via `getsockopt`.
`bigtable/tcp_stats.go` Public `TCPStats` type + `NewTCPStats()` constructor + `ClientOption()` returning `option.WithGRPCDialOption(grpc.WithContextDialer(...))`.
`bigtable/session_debug.go` Public accessors + type aliases so `debugview` never has to import `internal/transport`.

Wiring

  • `bigtable/client.go` — new `tcpStats` field on `Client`; `NewClientWithConfig` constructs it when `config.EnableDebug` is true, appends the ClientOption to the dial option list, threads `config.EnableDebug` into `session.NewClient`.
  • `bigtable/internal/session/client.go` — `NewClient` signature grows an `enableDebug bool` that populates `Config.EnableDebug` on the session-side pool.
  • `bigtable/internal/transport/debug_api.go` — `ChannelPoolDebug` grows `InstanceName` + `AppProfile` so channelz can render the top-line identifier without a reverse lookup.

What is NOT in this PR

  • `SessionAwareScaleMonitor` (session-count-driven channel pool sizing) — sits on its own branch and will open as a follow-up.
  • `AddChannelPoolConfigListener` on `ClientConfigurationManager` — SACM's registration hook; ships with SACM.
  • `BigtableChannelPool.TotalStreamCount` — SACM's input signal; ships with SACM.

Test plan

  • `go build ./...` under `bigtable/` clean
  • `go vet ./...` under `bigtable/` clean
  • `go test -short -count=1 -timeout=90s ./debugview/ ./internal/transport/ ./internal/session/` all pass

@sushanb
sushanb requested review from a team as code owners August 1, 2026 03:27
@product-auto-label product-auto-label Bot added the api: bigtable Issues related to the Bigtable API. label Aug 1, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive diagnostic debug view package (debugview) for the Cloud Bigtable Go client, adding endpoints like sessionz, afez, loadz, channelz, configz, tcpz, and debugtagsz to expose live internal client state. The review feedback highlights critical safety and robustness improvements, including handling time.MinDuration in duration rounding functions to prevent infinite recursion crashes, using url.PathEscape in manually constructed HTML links to avoid broken routing, and consistently utilizing the shared writeJSON and writeHTML helpers across the new handlers to improve code reuse and avoid duplicate header warnings.

Comment on lines +57 to +70
func roundDurationShort(d time.Duration) time.Duration {
switch {
case d < 0:
return -roundDurationShort(-d)
case d == 0:
return 0
case d < time.Millisecond:
return d.Round(time.Microsecond)
case d < time.Second:
return d.Round(time.Millisecond)
default:
return d.Round(10 * time.Millisecond)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If d is time.MinDuration, d < 0 is true, but -d overflows and remains time.MinDuration. This causes infinite recursion and a stack overflow, which immediately crashes the Go process. We should handle time.MinDuration safely to prevent potential crashes.

Suggested change
func roundDurationShort(d time.Duration) time.Duration {
switch {
case d < 0:
return -roundDurationShort(-d)
case d == 0:
return 0
case d < time.Millisecond:
return d.Round(time.Microsecond)
case d < time.Second:
return d.Round(time.Millisecond)
default:
return d.Round(10 * time.Millisecond)
}
}
func roundDurationShort(d time.Duration) time.Duration {
if d < 0 {
if d == time.MinDuration {
return time.MinDuration
}
return -roundDurationShort(-d)
}
switch {
case d == 0:
return 0
case d < time.Millisecond:
return d.Round(time.Microsecond)
case d < time.Second:
return d.Round(time.Millisecond)
default:
return d.Round(10 * time.Millisecond)
}
}

Comment on lines +75 to +86
func roundDurationLong(d time.Duration) time.Duration {
switch {
case d > time.Hour:
return d.Round(time.Minute)
case d > time.Minute:
return d.Round(time.Second)
case d > time.Second:
return d.Round(10 * time.Millisecond)
default:
return d.Round(time.Microsecond)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

roundDurationLong does not handle negative durations symmetrically (it falls back to microsecond rounding), unlike roundDurationShort. We should make it symmetric and safe for time.MinDuration as well.

Suggested change
func roundDurationLong(d time.Duration) time.Duration {
switch {
case d > time.Hour:
return d.Round(time.Minute)
case d > time.Minute:
return d.Round(time.Second)
case d > time.Second:
return d.Round(10 * time.Millisecond)
default:
return d.Round(time.Microsecond)
}
}
func roundDurationLong(d time.Duration) time.Duration {
if d < 0 {
if d == time.MinDuration {
return time.MinDuration
}
return -roundDurationLong(-d)
}
switch {
case d > time.Hour:
return d.Round(time.Minute)
case d > time.Minute:
return d.Round(time.Second)
case d > time.Second:
return d.Round(10 * time.Millisecond)
default:
return d.Round(time.Microsecond)
}
}

Comment on lines +117 to +129
poolEsc := template.HTMLEscapeString(r.PoolName)
nameEsc := template.HTMLEscapeString(r.LogName)
b.WriteString(`<a href="../sessionz/pool/`)
b.WriteString(poolEsc)
b.WriteString(`#`)
b.WriteString(nameEsc)
b.WriteString(`" title="jump to `)
b.WriteString(nameEsc)
b.WriteString(` in `)
b.WriteString(poolEsc)
b.WriteString(`">`)
b.WriteString(nameEsc)
b.WriteString(`</a>`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since this HTML is constructed manually and returned as template.HTML, it bypasses html/template's automatic contextual escaping. If r.PoolName or r.LogName contains special characters like ?, #, %, or spaces, the generated links will be broken or misrouted. We should use url.PathEscape for the URL path and fragment while keeping the unescaped (but HTMLEscaped) versions for the user-visible text and tooltips. Note that you will need to import "net/url" in this file.

			poolPath := template.HTMLEscapeString(url.PathEscape(r.PoolName))
			namePath := template.HTMLEscapeString(url.PathEscape(r.LogName))
			poolHTML := template.HTMLEscapeString(r.PoolName)
			nameHTML := template.HTMLEscapeString(r.LogName)
			b.WriteString("<a href=\"../sessionz/pool/")
			b.WriteString(poolPath)
			b.WriteString("#")
			b.WriteString(namePath)
			b.WriteString(\"\" title=\"jump to \")
			b.WriteString(nameHTML)
			b.WriteString(\" in \")
			b.WriteString(poolHTML)
			b.WriteString(\">\")
			b.WriteString(nameHTML)
			b.WriteString(\"</a>\")

Comment on lines +54 to +73
if r.URL.Query().Get("format") == "json" {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(pools)
return
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
// If Execute errors partway through, the body has already been
// (partially) written and the status code is committed to 200 —
// calling http.Error here would trigger a "superfluous
// WriteHeader" warning without doing anything useful for the
// client. Silently swallow; the partial page is what the caller
// gets to see.
_ = channelzTpl.Execute(w, channelzPageData{
Pools: pools,
Generated: time.Now(),
HasProvider: s.provider != nil,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency and robustness, we should use the shared writeJSON and writeHTML helpers defined in helpers.go instead of manually setting headers and executing the template.

Suggested change
if r.URL.Query().Get("format") == "json" {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(pools)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// If Execute errors partway through, the body has already been
// (partially) written and the status code is committed to 200 —
// calling http.Error here would trigger a "superfluous
// WriteHeader" warning without doing anything useful for the
// client. Silently swallow; the partial page is what the caller
// gets to see.
_ = channelzTpl.Execute(w, channelzPageData{
Pools: pools,
Generated: time.Now(),
HasProvider: s.provider != nil,
})
if r.URL.Query().Get("format") == "json" {
writeJSON(w, pools)
return
}
writeHTML(w, channelzTpl, channelzPageData{
Pools: pools,
Generated: time.Now(),
HasProvider: s.provider != nil,
})

Comment on lines +84 to +91
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := configzTpl.Execute(w, configzPageData{
HasProvider: s.provider != nil,
Snapshot: snap,
ConfigJSON: jsonStr,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We should use the shared writeHTML helper instead of manually setting headers and executing the template. This also avoids potential superfluous WriteHeader warnings if Execute fails after writing some bytes.

	writeHTML(w, configzTpl, configzPageData{
		HasProvider: s.provider != nil,
		Snapshot:    snap,
		ConfigJSON:  jsonStr,
	})

Comment on lines +819 to +824
if q.Get("format") == "json" {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(raw)
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We should use the shared writeJSON helper instead of manually setting headers and encoding the JSON.

	if q.Get("format") == "json" {
		writeJSON(w, raw)
		return
	}

Comment on lines +941 to +947
tpl := tcpzTpl
if flat {
tpl = tcpzFlatTpl
}
if err := tpl.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We should use the shared writeHTML helper instead of manually executing the template. This also avoids potential superfluous WriteHeader warnings if Execute fails after writing some bytes.

	tpl := tcpzTpl
	if flat {
		tpl = tcpzFlatTpl
	}
	writeHTML(w, tpl, data)

sushanb added 2 commits August 1, 2026 03:33
…ebug

Ports the 7-page /debug/ UI + per-connection TCP_INFO collector from
a downstream feature branch onto upstream/main. Wire the entire tree
with a one-liner:

    client, _ := bigtable.NewClientWithConfig(ctx, "p", "i",
        bigtable.ClientConfig{EnableDebug: true})
    http.Handle("/debug/", http.StripPrefix("/debug",
        debugview.Handler(client)))

Public API

  - ClientConfig.EnableDebug bool — opts a Client into the TCPStats
    collector. Zero cost when false: no dial hook, no allocation.
    When true, NewClientWithConfig constructs TCPStats and appends
    its dial option so every gRPC dial (classic + session pools)
    registers with the collector.
  - Client.SessionDebug / ChannelDebug / ConfigDebug / TCPStats
    accessors — hand these directly to debugview.Handler.
  - Type aliases in bigtable/session_debug.go re-export the debug
    provider interfaces + snapshot types from internal/transport so
    debugview depends only on the public bigtable package.

New packages

  - bigtable/debugview/ — 8 z-page HTTP handlers (sessionz, afez,
    loadz, channelz, configz, tcpz, debugtagsz) behind a single
    Handler(client) entry. Every view supports ?format=json.
    Nil-safe: panels render a "not enabled" banner when the
    corresponding accessor returns nil.
  - bigtable/internal/transport/conn_registry.go +
    tcp_info_{linux,other}.go — the collector's backing store and
    OS-specific TCP_INFO scrape via getsockopt.
  - bigtable/tcp_stats.go — public TCPStats type + NewTCPStats()
    constructor + ClientOption() returning
    option.WithGRPCDialOption(grpc.WithContextDialer(...)).

Wiring

  - client.go — new tcpStats field on Client; NewClientWithConfig
    constructs it when config.EnableDebug is true, appends the
    ClientOption to the dial option list, threads config.EnableDebug
    into session.NewClient.
  - internal/session/client.go — NewClient signature grows an
    enableDebug bool that populates Config.EnableDebug on the
    session-side pool.

Session-aware channel pool sizing is intentionally out of scope for
this PR — that stack lands separately.
…n prior commit)

The debugview commit staged bigtable/client.go's new session.NewClient
call signature but didn't stage the session-side signature bump. Adds
the missing 'enableDebug bool' parameter and Config.EnableDebug
propagation so the package compiles standalone.
@sushanb
sushanb force-pushed the feat/bigtable-debugview-only branch from 3ee4b11 to e10064d Compare August 1, 2026 03:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api: bigtable Issues related to the Bigtable API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant