Skip to content

Commit 4a519f4

Browse files
ryanyuansean-
andauthored
feat(opensearchtransport): collect per-request metrics by default (#896)
Per-request counters (requests, failures, responses-by-status) are now collected by default, independent of EnableMetrics. The metrics struct is always allocated and wired into the connection pools; EnableMetrics now sets only a `detailed` flag that gates the expensive detailed-metrics path (per-connection enumeration and the per-connection/per-policy/per-snapshot callbacks). Previously these counters were gated behind EnableMetrics alongside the expensive detailed path, so callers who wanted basic request/response counts had to opt into work they did not need, and internal consumers could not rely on the counters being populated. - The responses-by-status counter moves from a mutex-guarded map[int]int to a lock-free atomic.Int64 array indexed by status code, plus a single overflow bucket for out-of-range codes. (requests and failures were already atomic as of #776.) - Metrics() returns the per-request counters unconditionally and only builds the detailed, callback-augmented fields when detailed is enabled. It no longer returns an error when metrics are disabled. - Detailed-callback registration at all 7 policy sites is guarded by a nil-safe metrics.detailedEnabled() helper, so the counters-only path does zero per-snapshot work. - A custom ConnectionPoolFunc returning a single-URL pool no longer errors during construction: metrics wiring is a no-op on a pool type mismatch, mirroring the multi-node branch. - The public Metrics.Responses map keys out-of-range status codes under -1 (statusOverflow); documented on the field. Closes #891 Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com> Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> Co-authored-by: Ryan Yuan <ryan.yuan@crowdstrike.com> Co-authored-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 869a579 commit 4a519f4

15 files changed

Lines changed: 341 additions & 99 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
165165

166166
### Changed
167167

168+
- **BREAKING**: Per-request transport metrics (`requests`, `failures`, responses-by-status) are now always collected via lock-free atomics, independent of `EnableMetrics`. `EnableMetrics` now gates only the detailed-metrics snapshot (per-connection, per-policy, and router state returned by `Metrics()`). The responses-by-status counter moved from a mutex-guarded map to a lock-free atomic array. `Metrics()` no longer returns an error when metrics are disabled -- it always returns the per-request counters (callers that branched on `if err != nil` for the disabled case should drop that check). See [`UPGRADING_V5.md`](UPGRADING_V5.md#metrics-error-on-disabled-removed) for migration. ([#891](https://github.com/opensearch-project/opensearch-go/issues/891))
168169
- Reorganize the documentation. Split `UPGRADING.md` into a version-history index plus per-major-version guides (`UPGRADING_V5.md` through `UPGRADING_V2.md`) and rename `opensearchapi/MIGRATING.md` to `opensearchapi/UPGRADING_V4_TO_V5.md`. Group the `guides/` and `_samples/` files by subsystem (`transport-`, `indexing-`, `usage-`, `config-`) and add a `guides/README.md` index. Make `guides/usage-error_handling.md` the single source for partial-error handling and `guides/transport-retry_backoff.md` the single source for resurrection-timeout config, replacing the duplicated copies in `opensearchapi/README.md` and `guides/transport-routing.md` with links. Add package documentation (`doc.go`) for `opensearchapi`, `plugins`, `signer`, and `signer/awsv2`.
169170
- Trim the CI compatibility matrix to the currently-patched OpenSearch set (2.19.x and 3.x) per the 12-month support policy; older lines (1.3.x - 2.18.x) are no longer part of the tested matrix and the 4.x client remains their supported path. No client code change ([#856](https://github.com/opensearch-project/opensearch-go/issues/856))
170171
- **BREAKING**: Module path is now `github.com/opensearch-project/opensearch-go/v5`. Update import paths from `/v4` to `/v5`; the in-source `opensearchapi.X` package qualifier is unchanged

UPGRADING_V5.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,34 @@ if err := client.DiscoverNodes(ctx); err != nil {
9696

9797
The signature already changed earlier in this version to take `context.Context` (see CHANGELOG); this is a behavioral change on top of the signature change.
9898

99+
## Metrics error on disabled removed
100+
101+
The per-request transport counters (`Requests`, `Failures`, and responses-by-status) are now always collected via lock-free atomics, independent of `EnableMetrics`. As a result, `opensearch.Client.Metrics()` (and `opensearchtransport.Transport.Metrics()`) no longer returns the `"transport metrics not enabled"` error when `EnableMetrics` is false -- it always returns the per-request counters. `EnableMetrics` now gates only the detailed-metrics snapshot (per-connection enumeration, per-policy breakdowns, and router cache state); those fields stay zero/nil when it is unset.
102+
103+
Callers that branched on the error to detect the disabled state should drop that check. The returned error is now non-nil only when a detailed-metrics snapshot callback fails.
104+
105+
```go
106+
// v4: Metrics() errored when EnableMetrics was false, so callers used the
107+
// error to detect the disabled state.
108+
m, err := client.Metrics()
109+
if err != nil {
110+
// treated as "metrics disabled" -- no counters available
111+
return
112+
}
113+
use(m.Requests, m.Failures)
114+
115+
// v5: per-request counters are always populated. A non-nil error now means a
116+
// detailed-snapshot callback failed, not that metrics are disabled.
117+
m, err := client.Metrics()
118+
if err != nil {
119+
log.Printf("detailed metrics snapshot failed: %s", err)
120+
// m.Requests / m.Failures / m.Responses are still valid here
121+
}
122+
use(m.Requests, m.Failures)
123+
```
124+
125+
Detailed fields such as `Policies` and `Router` remain populated only when `EnableMetrics` is set; reading them without it yields nil, unchanged from v4.
126+
99127
## `opensearchtransport.Client` renamed to `opensearchtransport.Transport`
100128

101129
The concrete `opensearchtransport.Client` type was renamed to `opensearchtransport.Transport`. The type owns HTTP round-trip concerns -- connection pooling, retries, node selection, and discovery -- so `Transport` reflects its role and avoids colliding conceptually with the API clients above it (`opensearch.Client` and `opensearchapi.Client`).

guides/transport-metrics.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
The opensearch-go transport exposes a pull-based metrics API that returns a point-in-time snapshot of request counters, connection pool state, per-connection health, policy-level breakdowns, and router cache state. All fields are JSON-tagged for easy serialization.
44

5+
Metrics come in two tiers. The **per-request counters** (`requests`, `failures`, and responses-by-status) are always collected via lock-free atomics and returned by `Metrics()` regardless of configuration. The **detailed-metrics snapshot** (connection-pool state, per-connection health, per-policy breakdowns, and router cache state) is opt-in behind `EnableMetrics`.
6+
57
## Quick Start
68

7-
Metrics collection is opt-in. Set `EnableMetrics: true` on `opensearch.Config`; without it, `client.Metrics()` returns `"transport metrics not enabled"`. When constructing through `opensearchapi.NewClient`, set the flag on the embedded `opensearch.Config` and reach the method via `apiClient.Client.Metrics()`.
9+
The per-request counters require no configuration. To also populate the detailed-metrics snapshot, set `EnableMetrics: true` on `opensearch.Config`. When constructing through `opensearchapi.NewClient`, set the flag on the embedded `opensearch.Config` and reach the method via `apiClient.Client.Metrics()`.
810

911
```go
1012
client, err := opensearch.NewClient(opensearch.Config{
@@ -24,7 +26,7 @@ data, _ := json.MarshalIndent(m, "", " ")
2426
fmt.Println(string(data))
2527
```
2628

27-
The `Metrics()` method lives on `opensearch.Client`. It returns an `opensearchtransport.Metrics` struct and an error -- non-nil when metrics are disabled or a snapshot callback fails.
29+
The `Metrics()` method lives on `opensearch.Client`. It returns an `opensearchtransport.Metrics` struct and an error -- non-nil when a detailed-metrics snapshot callback fails. A `New()`-constructed transport always returns the per-request counters, so `Metrics()` does not error merely because `EnableMetrics` is unset.
2830

2931
---
3032

opensearchtransport/doc.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,10 @@ Use the EnableDebugLogger option to enable the debugging logger for connection m
198198
Alternatively, set the OPENSEARCH_GO_DEBUG environment variable to "true" to enable debug
199199
logging globally without code changes. When enabled, debug output is written to stderr.
200200
201-
Use the EnableMetrics option to enable metric collection and export.
201+
Use the EnableMetrics option to enable the detailed-metrics snapshot
202+
(per-connection, per-policy, and router state). The per-request counters
203+
(requests, failures, responses by status) are always collected and are
204+
returned by Metrics regardless of this option.
202205
203206
# Controlling the Router via Environment Variable
204207

opensearchtransport/metrics.go

Lines changed: 92 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,9 @@ package opensearchtransport
2929
import (
3030
"errors"
3131
"fmt"
32-
"maps"
3332
"slices"
3433
"strconv"
3534
"strings"
36-
"sync"
3735
"sync/atomic"
3836
"time"
3937
)
@@ -43,10 +41,23 @@ type Measurable interface {
4341
Metrics() (Metrics, error)
4442
}
4543

44+
// Response status codes are bounded to [statusMin, statusMax). Anything
45+
// outside that range is folded into a single overflow bucket so a malformed
46+
// upstream status is still counted rather than panicking on a bad index.
47+
const (
48+
statusMin = 100
49+
statusMax = 600
50+
statusBuckets = statusMax - statusMin // 500 in-range buckets
51+
statusOverflow = -1 // map key for the overflow bucket
52+
)
53+
4654
// Metrics represents the transport metrics.
4755
type Metrics struct {
48-
Requests int `json:"requests"`
49-
Failures int `json:"failures"`
56+
Requests int `json:"requests"`
57+
Failures int `json:"failures"`
58+
// Responses counts responses by HTTP status code. Any status outside the
59+
// valid [100, 600) range is folded into a single overflow bucket keyed by
60+
// -1 (statusOverflow) rather than its literal code.
5061
Responses map[int]int `json:"responses"`
5162

5263
// Connection pool state.
@@ -79,10 +90,12 @@ type Metrics struct {
7990

8091
Connections []fmt.Stringer `json:"connections"`
8192

82-
// Per-policy breakdown (only populated when router with policies is active)
93+
// Per-policy breakdown. Part of the detailed-metrics path: populated only
94+
// when EnableMetrics is set and a router with policies is active; nil otherwise.
8395
Policies []PolicySnapshot `json:"policies,omitempty"`
8496

85-
// Router cache state (only populated when scored routing is active)
97+
// Router cache state. Part of the detailed-metrics path: populated only
98+
// when EnableMetrics is set and scored routing is active; nil otherwise.
8699
Router *RouterSnapshot `json:"router,omitempty"`
87100
}
88101

@@ -214,6 +227,12 @@ type metrics struct {
214227
standbyPromotions atomic.Int64 // Standby -> Active
215228
standbyDemotions atomic.Int64 // Active -> Standby
216229

230+
// detailed gates the expensive detailed-metrics path: registration and
231+
// invocation of the per-connection / per-policy / per-snapshot callbacks and
232+
// the connection-state enumeration in Metrics. The cheap per-request counters
233+
// above are populated regardless of detailed. Set from Config.EnableMetrics.
234+
detailed bool
235+
217236
// Metric callbacks registered by policies at init time.
218237
// Immutable after client construction; no synchronization needed.
219238
connMetricCallbacks []ConnectionMetricCallback // batch per-connection
@@ -225,52 +244,61 @@ type metrics struct {
225244
addressResolverRewrites atomic.Int64 // Resolver returned a different URL
226245
addressResolverErrors atomic.Int64 // Resolver returned an error
227246

228-
mu struct {
229-
sync.RWMutex
230-
responses map[int]int
231-
}
247+
// responses counts HTTP responses by status code, lock-free. Index i holds
248+
// the count for status code statusMin+i; responsesOverflow holds any code
249+
// outside [statusMin, statusMax). Snapshotted in responsesSnapshot.
250+
responses [statusBuckets]atomic.Int64
251+
responsesOverflow atomic.Int64
252+
}
253+
254+
// detailedEnabled reports whether the detailed-metrics path is active. It is nil-safe:
255+
// a nil *metrics (no metrics struct wired) reports false, so callers can guard
256+
// detailed-only work with config.metrics.detailedEnabled() without a separate nil check.
257+
func (m *metrics) detailedEnabled() bool {
258+
return m != nil && m.detailed
232259
}
233260

234-
// incrementResponse increments the counter for the given status code.
261+
// incrementResponse records one response with the given status code. It is
262+
// lock-free: a single atomic add to the bucket for statusCode, or to the
263+
// overflow bucket when statusCode is outside [statusMin, statusMax).
235264
func (m *metrics) incrementResponse(statusCode int) {
236-
m.mu.Lock()
237-
m.mu.responses[statusCode]++
238-
m.mu.Unlock()
265+
if statusCode < statusMin || statusCode >= statusMax {
266+
m.responsesOverflow.Add(1)
267+
return
268+
}
269+
m.responses[statusCode-statusMin].Add(1)
270+
}
271+
272+
// responsesSnapshot returns a map of status code to count. Codes with a zero
273+
// count are omitted; the overflow bucket, when non-zero, is keyed by
274+
// statusOverflow.
275+
func (m *metrics) responsesSnapshot() map[int]int {
276+
out := make(map[int]int)
277+
for i := range m.responses {
278+
if n := m.responses[i].Load(); n > 0 {
279+
out[statusMin+i] = int(n)
280+
}
281+
}
282+
if n := m.responsesOverflow.Load(); n > 0 {
283+
out[statusOverflow] = int(n)
284+
}
285+
return out
239286
}
240287

241-
// Metrics returns the transport metrics.
288+
// Metrics returns the transport metrics. The detailed fields -- per-connection
289+
// enumeration, per-policy snapshots, and the router snapshot -- are populated
290+
// only when Config.EnableMetrics is set.
242291
func (c *Transport) Metrics() (Metrics, error) {
243292
if c.metrics == nil {
293+
// Defensive: a custom transport could embed *Transport without the
294+
// standard constructor. Treat as no metrics available.
244295
return Metrics{}, errors.New("transport metrics not enabled")
245296
}
246297

247-
// Build responses map with pre-allocated capacity (READ operation)
248-
c.metrics.mu.RLock()
249-
responses := make(map[int]int, len(c.metrics.mu.responses))
250-
maps.Copy(responses, c.metrics.mu.responses)
251-
c.metrics.mu.RUnlock()
252-
253-
// Get connections from current connection pool
254-
var ready, dead []*Connection
255-
var singleConns []*Connection
256-
c.mu.RLock()
257-
if c.mu.connectionPool != nil {
258-
switch pool := c.mu.connectionPool.(type) {
259-
case *multiServerPool:
260-
ready, dead = pool.connectionsByState()
261-
case *singleServerPool:
262-
singleConns = pool.connections()
263-
}
264-
}
265-
c.mu.RUnlock()
266-
267298
m := Metrics{
268299
Requests: int(c.metrics.requests.Load()),
269300
Failures: int(c.metrics.failures.Load()),
270-
Responses: responses,
271-
272-
LiveConnections: len(ready) + len(singleConns),
273-
DeadConnections: len(dead),
301+
Responses: c.metrics.responsesSnapshot(),
274302

275303
ConnectionsPromoted: int(c.metrics.connectionsPromoted.Load()),
276304
ConnectionsDemoted: int(c.metrics.connectionsDemoted.Load()),
@@ -280,7 +308,6 @@ func (c *Transport) Metrics() (Metrics, error) {
280308
ClusterHealthChecks: int(c.metrics.clusterHealthChecks.Load()),
281309
HealthChecksSuccess: int(c.metrics.healthChecksSuccess.Load()),
282310
HealthChecksFailed: int(c.metrics.healthChecksFailed.Load()),
283-
OverloadedServers: 0, // Set below when iterating connections
284311

285312
StandbyPromotions: int(c.metrics.standbyPromotions.Load()),
286313
StandbyDemotions: int(c.metrics.standbyDemotions.Load()),
@@ -290,6 +317,31 @@ func (c *Transport) Metrics() (Metrics, error) {
290317
AddressResolverErrors: int(c.metrics.addressResolverErrors.Load()),
291318
}
292319

320+
// Detailed-metrics path: connection enumeration + callbacks. The detailed-only
321+
// fields (LiveConnections, DeadConnections, OverloadedServers,
322+
// StandbyConnections, Connections, Policies, Router) stay zero/nil when
323+
// the detailed path is off.
324+
if !c.metrics.detailed {
325+
return m, nil
326+
}
327+
328+
// Get connections from current connection pool
329+
var ready, dead []*Connection
330+
var singleConns []*Connection
331+
c.mu.RLock()
332+
if c.mu.connectionPool != nil {
333+
switch pool := c.mu.connectionPool.(type) {
334+
case *multiServerPool:
335+
ready, dead = pool.connectionsByState()
336+
case *singleServerPool:
337+
singleConns = pool.connections()
338+
}
339+
}
340+
c.mu.RUnlock()
341+
342+
m.LiveConnections = len(ready) + len(singleConns)
343+
m.DeadConnections = len(dead)
344+
293345
// Build per-connection metrics. Each connection's connState atomic
294346
// determines isDead/isStandby/isOverloaded -- no positional tricks needed.
295347
//

0 commit comments

Comments
 (0)