diff --git a/internal/transport/nxapi/metrics.go b/internal/transport/nxapi/metrics.go new file mode 100644 index 000000000..9488e6b20 --- /dev/null +++ b/internal/transport/nxapi/metrics.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxapi + +import ( + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var ( + rpcDurationSeconds = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "nxapi_rpc_duration_seconds", + Help: "Duration of NX-API JSON-RPC requests in seconds.", + Buckets: prometheus.DefBuckets, + }, + []string{"target", "status"}, + ) + rpcCommandsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "nxapi_rpc_commands_total", + Help: "Total number of NX-API CLI commands sent.", + }, + []string{"target", "command", "status"}, + ) +) + +func init() { + metrics.Registry.MustRegister( + rpcDurationSeconds, + rpcCommandsTotal, + ) +} diff --git a/internal/transport/nxapi/nxapi.go b/internal/transport/nxapi/nxapi.go index 11103b142..3727d06e2 100644 --- a/internal/transport/nxapi/nxapi.go +++ b/internal/transport/nxapi/nxapi.go @@ -34,6 +34,7 @@ func (f RoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { type Client struct { client *http.Client url url.URL + target string } // Option configures a [Client]. @@ -62,6 +63,14 @@ func WithTimeout(d time.Duration) Option { } } +// WithTarget sets the device target label used in metrics. +func WithTarget(target string) Option { + return func(c *Client) error { + c.target = target + return nil + } +} + // NewClient creates a new [Client] for the given connection. // If the connection has a TLS configuration set, HTTPS is used; otherwise HTTP. func NewClient(conn *deviceutil.Connection, opts ...Option) (*Client, error) { @@ -111,8 +120,13 @@ func (c *Client) Do(ctx context.Context, r Request) ([]json.RawMessage, error) { return nil, fmt.Errorf("nxapi: failed to create request: %w", err) } + start := time.Now() resp, err := c.client.Do(req) if err != nil { + rpcDurationSeconds.WithLabelValues(c.target, "error").Observe(time.Since(start).Seconds()) + for _, cmd := range r { + rpcCommandsTotal.WithLabelValues(c.target, cmd.Params.Cmd, "error").Inc() + } return nil, fmt.Errorf("nxapi: failed to send request: %w", err) } defer resp.Body.Close() @@ -123,6 +137,10 @@ func (c *Client) Do(ctx context.Context, r Request) ([]json.RawMessage, error) { } if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rpcDurationSeconds.WithLabelValues(c.target, "error").Observe(time.Since(start).Seconds()) + for _, cmd := range r { + rpcCommandsTotal.WithLabelValues(c.target, cmd.Params.Cmd, "error").Inc() + } // Try to extract JSON-RPC errors from the body, but fall back to a // plain HTTPError if the response is not valid JSON-RPC (e.g. a 401 // from an nginx reverse proxy). @@ -140,6 +158,11 @@ func (c *Client) Do(ctx context.Context, r Request) ([]json.RawMessage, error) { return nil, &HTTPError{Code: resp.StatusCode, Body: body} } + rpcDurationSeconds.WithLabelValues(c.target, "success").Observe(time.Since(start).Seconds()) + for _, cmd := range r { + rpcCommandsTotal.WithLabelValues(c.target, cmd.Params.Cmd, "success").Inc() + } + res, err := decode(body) if err != nil { return nil, fmt.Errorf("nxapi: failed to decode response: %w", err)