Skip to content

feat(bigtable): add sessionTracer for per-Session lifecycle + vRPC metrics - #20190

Merged
sushanb merged 2 commits into
googleapis:mainfrom
sushanb:feat/bigtable-session-tracer
Jul 23, 2026
Merged

feat(bigtable): add sessionTracer for per-Session lifecycle + vRPC metrics#20190
sushanb merged 2 commits into
googleapis:mainfrom
sushanb:feat/bigtable-session-tracer

Conversation

@sushanb

@sushanb sushanb commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

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.

  • InitializeSessionMetricssync.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:

    Metric What it captures
    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). Documented in-file.

  • recordTransportOverhead gates 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, histogram Record) 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 pass
  • gofmt -l bigtable/internal/transport/session_tracer*.go — clean
  • go vet ./bigtable/internal/transport/ — clean

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.

…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).
@sushanb
sushanb requested review from a team as code owners July 22, 2026 18:41
@product-auto-label product-auto-label Bot added the api: bigtable Issues related to the Bigtable API. label Jul 22, 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 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.

Comment on lines +32 to +43
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
)

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +68 to +115
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
}

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

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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +197 to +217
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),
))
}

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

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),
	))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +228 to +254
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),
))
}

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

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),
	))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +278 to +293
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),
))
}

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

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),
	))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +305 to +317
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),
))
}

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

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),
	))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +228 to +246
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)
}

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

sushanb added a commit to sushanb/google-cloud-go that referenced this pull request Jul 22, 2026
…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.
@sushanb

sushanb commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Batch reply to all 7 gemini findings — they're all the "interface-value tear on concurrent init/read → use atomic.Value" note.

Technically correct per the Go memory model, but not a bug given the usage contract:

  • InitializeSessionMetrics is called synchronously from the Client constructor, before any Session goroutines exist. The sync.Once prevents re-init; the constructor ordering prevents read-during-write. go test -race on the tests bundled in this PR is clean (17 tests, all pass with -race).
  • The same package-global-histogram / package-global-counter pattern already shipped in debug_tracer.go:194 (debugTagCounter metric.Int64Counter, written once in registerDebugTagCounter, read from recordDebugTagAt) via feat(bigtable): add debug tag counter (recordDebugTag / assertDebugTag) #20114. Rewriting only session_tracer with atomic.Value would leave that sibling asymmetric.
  • If we ever want to harden, atomic.Pointer[metric.Float64Histogram] (Go 1.19+) is one line per recorder — cleaner than the atomic.Value + type-assert boilerplate the bot proposed. Happy to do that in a follow-up covering both files if a human reviewer wants it.

The 7th comment (session_tracer_test.go:246, TestSessionTracer_NilHistogramsAreNoOps) is moot — that test doesn't exist on this branch. See the in-file NOTE at line 222 explaining why it was intentionally omitted (save/restore of the package globals leaked heartbeat goroutines from unrelated tests).

if sessionOpenLatencies == nil {
return
}
snap := t.snapshot()

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Refactored in 940bd44sessionTracer 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.

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.

there should always be a close reason.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.
@sushanb
sushanb merged commit a466345 into googleapis:main Jul 23, 2026
19 checks passed
hongalex pushed a commit that referenced this pull request Jul 23, 2026
🤖 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>
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.

2 participants