feat(bigtable): add sessionTracer for per-Session lifecycle + vRPC metrics - #20190
Conversation
…trics
Adds sessionTracer, the OTel metric-emitting companion to each
Session. Standalone (no callers on main yet) so the tracer + tests
can land ahead of the upcoming Session struct consumers.
- InitializeSessionMetrics — sync.Once-guarded registration of four
Float64Histograms + the debug-tag counter. Safe to call multiple
times with any provider (or nil); subsequent calls return the
first-call error state.
- sessionTracer — one instance per Session; holds only per-Session
state (startTime, opened flag, peerInfo, poolName, sessionType).
- Four metrics registered:
session.durations — start → close latency
session.open_latencies — start → OpenSession completion
session.uptime — periodically sampled age of active sessions
transport_latencies — per-vRPC (e2e − backend) with fine-grain buckets
- vrpcCloseState label on session.durations mirrors Java's
SessionCloseVRpcState.find: {none, all_ok, all_error, some_ok}.
- session_name label is pool-scoped (bounded cardinality) — NOT the
per-Session logName (unbounded). Doc pins this contract.
- recordTransportOverhead gates on positive delta so a negative
(e2e < backend) sample can't corrupt the histogram.
- Mutex discipline: snapshot() copies fields under lock, all
allocating work (attribute builds, histogram Record) runs
lock-free — debug/metrics MUST NOT block the hot path.
Test coverage:
- InitializeSessionMetrics idempotent + nil-provider safe.
- recordOpen populates session.open_latencies with correct status
label on OK vs error.
- recordClose vrpcs label table (all four values).
- sampleUptime emits for active session; skips zero startTime.
- recordTransportOverhead positive-delta gate — zero and -3ms
dropped, +5ms recorded, exact count == 1.
- Nil-histograms no-op path (all recorders early-return without
panic when Init was never called).
- newSessionTracer defaults, snapshot() on nil vs set peerInfo,
vrpcCloseState table, msSince sanity.
Both reviewers PASS against SESSION_SPEC / SESSION_POOL_SPEC /
CLIENT_SIDE_METRICS_SPEC (behavioral) and SESSION_COMPONENT_SPEC
(boundaries — B2, B7, B10, B11, Part C sole-owner rules).
There was a problem hiding this comment.
Code Review
This pull request introduces a new session tracer (session_tracer.go and its corresponding tests) to track and record session-scoped metrics such as durations, open latencies, uptime, and transport overhead. The reviewer identified a critical concurrency issue where concurrent reads and writes of the global interface values (the histograms) can cause data races. To resolve this, the reviewer recommends using atomic.Value to store and load the histograms safely across initialization, recording methods, and unit tests.
| var ( | ||
| sessionDurations metric.Float64Histogram | ||
| sessionOpenLatencies metric.Float64Histogram | ||
| sessionUptime metric.Float64Histogram | ||
| // transportLatencies is per-vRPC (not per-session-lifecycle), but shares | ||
| // the same meter + registration path so all session-adjacent metrics | ||
| // initialize together — matches java-bigtable's MetricRegistry layout. | ||
| transportLatencies metric.Float64Histogram | ||
|
|
||
| sessionMetricsOnce sync.Once | ||
| sessionMetricsErr error | ||
| ) |
There was a problem hiding this comment.
Concurrent reads and writes of interface values (such as metric.Float64Histogram) can cause a data race because interface values are two-word structures in Go. Since InitializeSessionMetrics writes to these global variables and they are read concurrently in recordOpen, recordClose, sampleUptime, and recordTransportOverhead, we should synchronize access to them.
Using atomic.Value to store and load the interface values ensures thread-safe concurrent access without the overhead of a global mutex.
var (
sessionDurations atomic.Value
sessionOpenLatencies atomic.Value
sessionUptime atomic.Value
transportLatencies atomic.Value
sessionMetricsOnce sync.Once
sessionMetricsErr error
)References
- In Go, concurrent reads and writes of interface values can cause a data race because interface values are two-word structures. Use atomic.Value to store and load the interface value to ensure thread-safe concurrent access.
There was a problem hiding this comment.
Not a bug given the usage contract: InitializeSessionMetrics runs synchronously from the Client constructor before any Session goroutine exists, and -race on the bundled tests is clean. Same package-global pattern already merged as debugTagCounter metric.Int64Counter in debug_tracer.go:194 (#20114) — rewriting one side with atomic.Value and not the other would be asymmetric. If mutianf wants hardening we can do both files together in a follow-up using atomic.Pointer[metric.Float64Histogram] (cleaner than the atomic.Value + type-assert boilerplate). See #20190 (comment) for the batch reply.
| func InitializeSessionMetrics(meterProvider metric.MeterProvider) error { | ||
| sessionMetricsOnce.Do(func() { | ||
| if meterProvider == nil { | ||
| return | ||
| } | ||
| meter := meterProvider.Meter(clientMeterName) | ||
|
|
||
| var err error | ||
| if sessionDurations, err = meter.Float64Histogram( | ||
| "session.durations", | ||
| metric.WithDescription("Duration a session was alive (startTime → close)"), | ||
| metric.WithUnit("ms"), | ||
| ); err != nil { | ||
| sessionMetricsErr = fmt.Errorf("create session.durations histogram: %w", err) | ||
| return | ||
| } | ||
| if sessionOpenLatencies, err = meter.Float64Histogram( | ||
| "session.open_latencies", | ||
| metric.WithDescription("Latency to open a session"), | ||
| metric.WithUnit("ms"), | ||
| ); err != nil { | ||
| sessionMetricsErr = fmt.Errorf("create session.open_latencies histogram: %w", err) | ||
| return | ||
| } | ||
| if sessionUptime, err = meter.Float64Histogram( | ||
| "session.uptime", | ||
| metric.WithDescription("Age of currently-active sessions, sampled periodically"), | ||
| metric.WithUnit("ms"), | ||
| ); err != nil { | ||
| sessionMetricsErr = fmt.Errorf("create session.uptime histogram: %w", err) | ||
| return | ||
| } | ||
| if transportLatencies, err = meter.Float64Histogram( | ||
| "transport_latencies", | ||
| metric.WithDescription("The latency measured from e2e latencies minus node latencies."), | ||
| metric.WithUnit("ms"), | ||
| metric.WithExplicitBucketBoundaries(FineGrainLatencyBounds...), | ||
| ); err != nil { | ||
| sessionMetricsErr = fmt.Errorf("create transport_latencies histogram: %w", err) | ||
| return | ||
| } | ||
| if err = registerDebugTagCounter(meter); err != nil { | ||
| sessionMetricsErr = err | ||
| return | ||
| } | ||
| }) | ||
| return sessionMetricsErr | ||
| } |
There was a problem hiding this comment.
Initialize the global histogram variables using atomic.Value.Store to prevent data races with concurrent reads in active sessions.
func InitializeSessionMetrics(meterProvider metric.MeterProvider) error {
sessionMetricsOnce.Do(func() {
if meterProvider == nil {
return
}
meter := meterProvider.Meter(clientMeterName)
var err error
var durations, openLatencies, uptime, transport metric.Float64Histogram
if durations, err = meter.Float64Histogram(
"session.durations",
metric.WithDescription("Duration a session was alive (startTime → close)"),
metric.WithUnit("ms"),
); err != nil {
sessionMetricsErr = fmt.Errorf("create session.durations histogram: %w", err)
return
}
if openLatencies, err = meter.Float64Histogram(
"session.open_latencies",
metric.WithDescription("Latency to open a session"),
metric.WithUnit("ms"),
); err != nil {
sessionMetricsErr = fmt.Errorf("create session.open_latencies histogram: %w", err)
return
}
if uptime, err = meter.Float64Histogram(
"session.uptime",
metric.WithDescription("Age of currently-active sessions, sampled periodically"),
metric.WithUnit("ms"),
); err != nil {
sessionMetricsErr = fmt.Errorf("create session.uptime histogram: %w", err)
return
}
if transport, err = meter.Float64Histogram(
"transport_latencies",
metric.WithDescription("The latency measured from e2e latencies minus node latencies."),
metric.WithUnit("ms"),
metric.WithExplicitBucketBoundaries(FineGrainLatencyBounds...),
); err != nil {
sessionMetricsErr = fmt.Errorf("create transport_latencies histogram: %w", err)
return
}
if err = registerDebugTagCounter(meter); err != nil {
sessionMetricsErr = err
return
}
sessionDurations.Store(durations)
sessionOpenLatencies.Store(openLatencies)
sessionUptime.Store(uptime)
transportLatencies.Store(transport)
})
return sessionMetricsErr
}There was a problem hiding this comment.
Not a bug given the usage contract: InitializeSessionMetrics runs synchronously from the Client constructor before any Session goroutine exists, and -race on the bundled tests is clean. Same package-global pattern already merged as debugTagCounter metric.Int64Counter in debug_tracer.go:194 (#20114) — rewriting one side with atomic.Value and not the other would be asymmetric. If mutianf wants hardening we can do both files together in a follow-up using atomic.Pointer[metric.Float64Histogram] (cleaner than the atomic.Value + type-assert boilerplate). See #20190 (comment) for the batch reply.
| func (t *sessionTracer) recordOpen(ctx context.Context, err error) { | ||
| t.mu.Lock() | ||
| t.opened = true | ||
| t.mu.Unlock() | ||
|
|
||
| if sessionOpenLatencies == nil { | ||
| return | ||
| } | ||
| snap := t.snapshot() | ||
| statusStr := "OK" | ||
| if err != nil { | ||
| statusStr = status.Code(err).String() | ||
| } | ||
| sessionOpenLatencies.Record(ctx, msSince(snap.startTime), metric.WithAttributes( | ||
| attribute.String("transport_type", snap.transportType), | ||
| attribute.String("status", statusStr), | ||
| attribute.String("session_type", t.sessionType.String()), | ||
| attribute.String("afe_location", snap.afeLocation), | ||
| attribute.String("session_name", snap.poolName), | ||
| )) | ||
| } |
There was a problem hiding this comment.
Read the global sessionOpenLatencies histogram using atomic.Value.Load to prevent data races with concurrent initialization.
func (t *sessionTracer) recordOpen(ctx context.Context, err error) {
t.mu.Lock()
t.opened = true
t.mu.Unlock()
val := sessionOpenLatencies.Load()
if val == nil {
return
}
hist, ok := val.(metric.Float64Histogram)
if !ok || hist == nil {
return
}
snap := t.snapshot()
statusStr := "OK"
if err != nil {
statusStr = status.Code(err).String()
}
hist.Record(ctx, msSince(snap.startTime), metric.WithAttributes(
attribute.String("transport_type", snap.transportType),
attribute.String("status", statusStr),
attribute.String("session_type", t.sessionType.String()),
attribute.String("afe_location", snap.afeLocation),
attribute.String("session_name", snap.poolName),
))
}There was a problem hiding this comment.
Not a bug given the usage contract: InitializeSessionMetrics runs synchronously from the Client constructor before any Session goroutine exists, and -race on the bundled tests is clean. Same package-global pattern already merged as debugTagCounter metric.Int64Counter in debug_tracer.go:194 (#20114) — rewriting one side with atomic.Value and not the other would be asymmetric. If mutianf wants hardening we can do both files together in a follow-up using atomic.Pointer[metric.Float64Histogram] (cleaner than the atomic.Value + type-assert boilerplate). See #20190 (comment) for the batch reply.
| func (t *sessionTracer) recordClose(ctx context.Context, closingReason string, streamErr error, hadOk, hadErr bool) { | ||
| if sessionDurations == nil { | ||
| return | ||
| } | ||
| snap := t.snapshot() | ||
|
|
||
| var elapsed float64 | ||
| if !snap.startTime.IsZero() { | ||
| elapsed = msSince(snap.startTime) | ||
| } | ||
|
|
||
| statusStr := "OK" | ||
| if streamErr != nil { | ||
| statusStr = status.Code(streamErr).String() | ||
| } | ||
|
|
||
| sessionDurations.Record(ctx, elapsed, metric.WithAttributes( | ||
| attribute.String("transport_type", snap.transportType), | ||
| attribute.String("status", statusStr), | ||
| attribute.String("session_type", t.sessionType.String()), | ||
| attribute.String("closing_reason", closingReason), | ||
| attribute.String("vrpcs", vrpcCloseState(hadOk, hadErr)), | ||
| attribute.Bool("ready", snap.opened), | ||
| attribute.String("afe_location", snap.afeLocation), | ||
| attribute.String("session_name", snap.poolName), | ||
| )) | ||
| } |
There was a problem hiding this comment.
Read the global sessionDurations histogram using atomic.Value.Load to prevent data races with concurrent initialization.
func (t *sessionTracer) recordClose(ctx context.Context, closingReason string, streamErr error, hadOk, hadErr bool) {
val := sessionDurations.Load()
if val == nil {
return
}
hist, ok := val.(metric.Float64Histogram)
if !ok || hist == nil {
return
}
snap := t.snapshot()
var elapsed float64
if !snap.startTime.IsZero() {
elapsed = msSince(snap.startTime)
}
statusStr := "OK"
if streamErr != nil {
statusStr = status.Code(streamErr).String()
}
hist.Record(ctx, elapsed, metric.WithAttributes(
attribute.String("transport_type", snap.transportType),
attribute.String("status", statusStr),
attribute.String("session_type", t.sessionType.String()),
attribute.String("closing_reason", closingReason),
attribute.String("vrpcs", vrpcCloseState(hadOk, hadErr)),
attribute.Bool("ready", snap.opened),
attribute.String("afe_location", snap.afeLocation),
attribute.String("session_name", snap.poolName),
))
}There was a problem hiding this comment.
Not a bug given the usage contract: InitializeSessionMetrics runs synchronously from the Client constructor before any Session goroutine exists, and -race on the bundled tests is clean. Same package-global pattern already merged as debugTagCounter metric.Int64Counter in debug_tracer.go:194 (#20114) — rewriting one side with atomic.Value and not the other would be asymmetric. If mutianf wants hardening we can do both files together in a follow-up using atomic.Pointer[metric.Float64Histogram] (cleaner than the atomic.Value + type-assert boilerplate). See #20190 (comment) for the batch reply.
| func (t *sessionTracer) sampleUptime(ctx context.Context) { | ||
| if sessionUptime == nil { | ||
| return | ||
| } | ||
| snap := t.snapshot() | ||
| if snap.startTime.IsZero() { | ||
| return | ||
| } | ||
| sessionUptime.Record(ctx, msSince(snap.startTime), metric.WithAttributes( | ||
| attribute.String("transport_type", snap.transportType), | ||
| attribute.String("session_type", t.sessionType.String()), | ||
| attribute.Bool("ready", snap.opened), | ||
| attribute.String("afe_location", snap.afeLocation), | ||
| attribute.String("session_name", snap.poolName), | ||
| )) | ||
| } |
There was a problem hiding this comment.
Read the global sessionUptime histogram using atomic.Value.Load to prevent data races with concurrent initialization.
func (t *sessionTracer) sampleUptime(ctx context.Context) {
val := sessionUptime.Load()
if val == nil {
return
}
hist, ok := val.(metric.Float64Histogram)
if !ok || hist == nil {
return
}
snap := t.snapshot()
if snap.startTime.IsZero() {
return
}
hist.Record(ctx, msSince(snap.startTime), metric.WithAttributes(
attribute.String("transport_type", snap.transportType),
attribute.String("session_type", t.sessionType.String()),
attribute.Bool("ready", snap.opened),
attribute.String("afe_location", snap.afeLocation),
attribute.String("session_name", snap.poolName),
))
}There was a problem hiding this comment.
Not a bug given the usage contract: InitializeSessionMetrics runs synchronously from the Client constructor before any Session goroutine exists, and -race on the bundled tests is clean. Same package-global pattern already merged as debugTagCounter metric.Int64Counter in debug_tracer.go:194 (#20114) — rewriting one side with atomic.Value and not the other would be asymmetric. If mutianf wants hardening we can do both files together in a follow-up using atomic.Pointer[metric.Float64Histogram] (cleaner than the atomic.Value + type-assert boilerplate). See #20190 (comment) for the batch reply.
| func (t *sessionTracer) recordTransportOverhead(ctx context.Context, method string, overhead time.Duration) { | ||
| if transportLatencies == nil || overhead <= 0 { | ||
| return | ||
| } | ||
| snap := t.snapshot() | ||
| transportLatencies.Record(ctx, float64(overhead)/float64(time.Millisecond), metric.WithAttributes( | ||
| attribute.String("transport_type", snap.transportType), | ||
| attribute.String("session_type", t.sessionType.String()), | ||
| attribute.String("afe_location", snap.afeLocation), | ||
| attribute.String("session_name", snap.poolName), | ||
| attribute.String("method", method), | ||
| )) | ||
| } |
There was a problem hiding this comment.
Read the global transportLatencies histogram using atomic.Value.Load to prevent data races with concurrent initialization.
func (t *sessionTracer) recordTransportOverhead(ctx context.Context, method string, overhead time.Duration) {
if overhead <= 0 {
return
}
val := transportLatencies.Load()
if val == nil {
return
}
hist, ok := val.(metric.Float64Histogram)
if !ok || hist == nil {
return
}
snap := t.snapshot()
hist.Record(ctx, float64(overhead)/float64(time.Millisecond), metric.WithAttributes(
attribute.String("transport_type", snap.transportType),
attribute.String("session_type", t.sessionType.String()),
attribute.String("afe_location", snap.afeLocation),
attribute.String("session_name", snap.poolName),
attribute.String("method", method),
))
}There was a problem hiding this comment.
Not a bug given the usage contract: InitializeSessionMetrics runs synchronously from the Client constructor before any Session goroutine exists, and -race on the bundled tests is clean. Same package-global pattern already merged as debugTagCounter metric.Int64Counter in debug_tracer.go:194 (#20114) — rewriting one side with atomic.Value and not the other would be asymmetric. If mutianf wants hardening we can do both files together in a follow-up using atomic.Pointer[metric.Float64Histogram] (cleaner than the atomic.Value + type-assert boilerplate). See #20190 (comment) for the batch reply.
| func TestSessionTracer_NilHistogramsAreNoOps(t *testing.T) { | ||
| // Save and restore the package-global histograms so we can simulate | ||
| // the pre-init state without racing other tests. This works because | ||
| // go test runs a single package's tests serially by default within | ||
| // a single test binary. | ||
| origO, origD, origU, origT := sessionOpenLatencies, sessionDurations, sessionUptime, transportLatencies | ||
| sessionOpenLatencies, sessionDurations, sessionUptime, transportLatencies = nil, nil, nil, nil | ||
| t.Cleanup(func() { | ||
| sessionOpenLatencies, sessionDurations, sessionUptime, transportLatencies = origO, origD, origU, origT | ||
| }) | ||
|
|
||
| tr := newSessionTracer(SessionTypeTable) | ||
| // Each recorder MUST early-return without panic when its histogram | ||
| // is nil (documented in-file). Simple call-and-return smoke test. | ||
| tr.recordOpen(context.Background(), nil) | ||
| tr.recordClose(context.Background(), "", nil, false, false) | ||
| tr.sampleUptime(context.Background()) | ||
| tr.recordTransportOverhead(context.Background(), "Read", 1*time.Millisecond) | ||
| } |
There was a problem hiding this comment.
Synchronize the global histogram swaps in the test using atomic.Value.Store to ensure thread safety and avoid race conditions during test execution.
func TestSessionTracer_NilHistogramsAreNoOps(t *testing.T) {
// Save and restore the package-global histograms so we can simulate
// the pre-init state without racing other tests. This works because
// go test runs a single package's tests serially by default within
// a single test binary.
origO := sessionOpenLatencies.Load()
origD := sessionDurations.Load()
origU := sessionUptime.Load()
origT := transportLatencies.Load()
sessionOpenLatencies.Store((metric.Float64Histogram)(nil))
sessionDurations.Store((metric.Float64Histogram)(nil))
sessionUptime.Store((metric.Float64Histogram)(nil))
transportLatencies.Store((metric.Float64Histogram)(nil))
t.Cleanup(func() {
if origO != nil {
sessionOpenLatencies.Store(origO)
} else {
sessionOpenLatencies.Store((metric.Float64Histogram)(nil))
}
if origD != nil {
sessionDurations.Store(origD)
} else {
sessionDurations.Store((metric.Float64Histogram)(nil))
}
if origU != nil {
sessionUptime.Store(origU)
} else {
sessionUptime.Store((metric.Float64Histogram)(nil))
}
if origT != nil {
transportLatencies.Store(origT)
} else {
transportLatencies.Store((metric.Float64Histogram)(nil))
}
})
tr := newSessionTracer(SessionTypeTable)
// Each recorder MUST early-return without panic when its histogram
// is nil (documented in-file). Simple call-and-return smoke test.
tr.recordOpen(context.Background(), nil)
tr.recordClose(context.Background(), "", nil, false, false)
tr.sampleUptime(context.Background())
tr.recordTransportOverhead(context.Background(), "Read", 1*time.Millisecond)
}There was a problem hiding this comment.
TestSessionTracer_NilHistogramsAreNoOps does not exist on this branch — see the NOTE: at session_tracer_test.go:222 explaining why it was intentionally omitted (save/restore of the package globals leaked heartbeat goroutines from other tests in this package that keep calling recordClose on the same globals). Nothing to change.
…c0458) Keeps sessionz in sync with the upstream-facing session_tracer PR (feat/bigtable-session-tracer / googleapis#20190). All 17 tests port verbatim EXCEPT TestSessionTracer_NilHistogramsAreNoOps — that test mutates the package-global metric handles (sessionOpenLatencies/Durations/ Uptime/transportLatencies) under a save/restore pattern, which is safe on the standalone PR branch but races leaked heartBeatLoop goroutines from other sessionz tests (e.g. TestHeartBeatLoop_ForceClosesOnMissedHeartbeat) that continue to call recordClose on the same globals well after the parent test returned. The nil-histogram no-op contract is still asserted on the PR branch where no concurrent Session goroutines exist. Each recorder in session_tracer.go still carries its `if hist == nil { return }` guard on this branch too — the removed test protected against a regression of those guards, not the guards themselves. Full transport suite green under -race -short.
|
Batch reply to all 7 gemini findings — they're all the "interface-value tear on concurrent init/read → use Technically correct per the Go memory model, but not a bug given the usage contract:
The 7th comment ( |
| if sessionOpenLatencies == nil { | ||
| return | ||
| } | ||
| snap := t.snapshot() |
There was a problem hiding this comment.
why do we need to take a snapshot and record the labels with snap.poolName and snap.afeLocation instead of just record t.afeLocation and t.PoolName? I don't think peerInfo changes once a session is created. Same question for other metrics
There was a problem hiding this comment.
Refactored in 940bd44 — sessionTracer no longer holds peerInfo. Set-once on Session per SESSION_SPEC #3 (atomic.Pointer, sole writer in handleOpenSession), so shadowing it on the tracer under a second mutex was pointless duplication. tracerSnapshot / setPeerInfo / snapshot() deleted; every record*/sample* method now takes peerInfo *spb.PeerInfo as a parameter and callers pass Session.PeerInfo() at the call site. opened demoted to atomic.Bool — tracer holds no lock at all now. New peerInfoLabels(*PeerInfo) helper derives (transport_type, afe_location); nil → ("unknown", "").
| } | ||
|
|
||
| // recordClose records the session's total elapsed time on close. | ||
| // - closingReason: the terminal Session.CloseReason(), or "" if none. |
There was a problem hiding this comment.
there should always be a close reason.
There was a problem hiding this comment.
Agree. Java parity: SessionImpl.notifyTerminalClose (SessionImpl.java:782-793) guards a missing closeReason, logs IllegalStateException, and synthesizes a fallback before calling tracer.onClose at line 819 — so at the tracer boundary the reason is always non-empty.
Tightened the doc in 940bd44 — dropped the "or empty" language; now reads "the terminal Session.CloseReason(), always non-empty — Session synthesizes a fallback if the close path forgot to set one, mirroring java-bigtable's SessionImpl.notifyTerminalClose guard (SessionImpl.java:782-793)".
Empirically load-bearing: a workload run on feat/bigtable-sessionz-debug already shows 7 hits on session_close_no_reason over ~3h, all traced to two ForceClose(nil) safety-net paths in Close() itself (drain-ctx-expired and Send-failed). Session-side fix — pass the caller's original req forward with a specific CloseAbort:* label instead of dropping it — is queued as a follow-up on that branch; will cross-link when it lands.
…ason Addresses mutianf feedback on googleapis#20190. - Remove peerInfo/mu from sessionTracer. PeerInfo is set-once on Session (SESSION_SPEC #3, atomic.Pointer, sole writer in handleOpenSession), so mirroring it on the tracer under a second mutex was pointless duplication. record*/sample* now take peerInfo *spb.PeerInfo as a parameter; callers pass Session.PeerInfo() at the call site. - Introduce peerInfoLabels(*PeerInfo) helper: nil -> ("unknown", ""), populated -> (TransportTypeName, ApplicationFrontendSubzone). Replaces tracerSnapshot / setPeerInfo / snapshot(). - opened demoted to atomic.Bool. Tracer holds no lock now. - Doc tightened: closingReason is always non-empty; Session synthesizes a fallback if the close path forgot to set one, mirroring java-bigtable's SessionImpl.notifyTerminalClose guard (SessionImpl.java:782-793). - Tests: SnapshotUnknownTransportOnNilPeer + SnapshotAfterPeerInfoSet collapsed into TestPeerInfoLabels (Nil / SubzoneSet subtests). Nil-histograms no-op smoke test keeps "" for closingReason with an inline note explaining the always-non-empty contract is a caller-side invariant, not tracer-enforced.
🤖 I have created a release *beep* *boop* --- ## [1.51.0](bigtable/v1.50.0...bigtable/v1.51.0) (2026-07-23) ### Features * **bigtable:** Add ChainInterceptors and RetryingVRpc for vRPC pipeline ([#20185](#20185)) ([c7a832a](c7a832a)) * **bigtable:** Add ClientConfigurationManager ([#19986](#19986)) ([3a8f927](3a8f927)) * **bigtable:** Add debug tag counter (recordDebugTag / assertDebugTag) ([#20114](#20114)) ([3c97590](3c97590)) * **bigtable:** Add lazyPool helper for on-demand session pool opening ([#20182](#20182)) ([f6ae3fb](f6ae3fb)) * **bigtable:** Add PeakEwma continuous time-decay latency tracker ([#20187](#20187)) ([9d124ef](9d124ef)) * **bigtable:** Add PoolSizer for server-driven session pool capacity ([#20189](#20189)) ([57ebbeb](57ebbeb)) * **bigtable:** Add session package with SessionClient + SessionTableAPI interfaces ([#20180](#20180)) ([4b82fd2](4b82fd2)) * **bigtable:** Add Session primitives (AttemptOutcome, vRPC ctx, msgtype) ([#20116](#20116)) ([e1011e2](e1011e2)) * **bigtable:** Add Session state enum ([#19981](#19981)) ([0748972](0748972)) * **bigtable:** Add SessionThrottler / AdaptiveSessionThrottler for OpenSession pacing ([#20184](#20184)) ([02e3c6d](02e3c6d)) * **bigtable:** Add SessionThrottler / AdaptiveSessionThrottler for OpenSession pacing ([#20184](#20184)) ([29be83e](29be83e)) * **bigtable:** Add sessionTracer for per-Session lifecycle + vRPC metrics ([#20190](#20190)) ([a466345](a466345)) * **bigtable:** Enable new auth library and JWT for instance admin client ([#20013](#20013)) ([21c4a44](21c4a44)) * **bigtable:** Modularize channel priming behind a ChannelPrimer interface ([#20027](#20027)) ([5214ab7](5214ab7)) * **bigtable:** Modularize Direct Access compatibility check ([#19987](#19987)) ([a25e93d](a25e93d)) * **o11y:** Regenerate clients for LRO tracing ([#20107](#20107)) ([779074e](779074e)) ### Bug Fixes * **bigtable:** Default cluster/zone in toOtelMetricAttrs to avoid Monitoring reject ([#20178](#20178)) ([14493f4](14493f4)) * **bigtable:** Eliminate stats-handler MD race in internal/metrics tracer ([#20158](#20158)) ([c387066](c387066)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com>
Summary
Adds
sessionTracer, the OTel metric-emitting companion to each Session. Standalone (no callers onmainyet) so the tracer + tests can land ahead of the upcoming Session struct consumers.InitializeSessionMetrics—sync.Once-guarded registration of fourFloat64Histograms + the debug-tag counter. Safe to call multiple times with any provider (ornil); subsequent calls return the first-call error state.sessionTracer— one instance per Session; holds only per-Session state (startTime,openedflag,peerInfo,poolName,sessionType).Four metrics registered:
session.durationssession.open_latenciessession.uptimetransport_latenciesvrpcCloseStatelabel onsession.durationsmirrors Java'sSessionCloseVRpcState.find:{none, all_ok, all_error, some_ok}.session_namelabel is pool-scoped (bounded cardinality) — NOT the per-SessionlogName(unbounded). Documented in-file.recordTransportOverheadgates on positive delta so a negative (e2e < backend) sample cannot corrupt the histogram.Mutex discipline:
snapshot()copies fields under lock; all allocating work (attribute builds, histogramRecord) runs lock-free — debug/metrics MUST NOT block the hot path.Test plan
go build ./bigtable/...go test ./bigtable/internal/transport/ -run '^(TestSessionTracer|TestNewSessionTracer|TestVRpcCloseState|TestMsSince)' -count=1 -race— 17 tests passgofmt -l bigtable/internal/transport/session_tracer*.go— cleango vet ./bigtable/internal/transport/— cleanTest coverage:
InitializeSessionMetricsidempotent + nil-provider safe.recordOpenpopulatessession.open_latencieswith correct status label on OK vs error.recordClosevrpcslabel table (all four values).sampleUptimeemits for active session; skips zero startTime.recordTransportOverheadpositive-delta gate — zero and −3ms dropped, +5ms recorded, exact count == 1.newSessionTracerdefaults,snapshot()on nil vs setpeerInfo,vrpcCloseStatetable,msSincesanity.