Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion central/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import (
logimbueHandler "github.com/stackrox/rox/central/logimbue/handler"
metadataService "github.com/stackrox/rox/central/metadata/service"
customMetrics "github.com/stackrox/rox/central/metrics/custom"
"github.com/stackrox/rox/central/metrics/custom/api_requests"
"github.com/stackrox/rox/central/metrics/telemetry"
mitreService "github.com/stackrox/rox/central/mitre/service"
namespaceService "github.com/stackrox/rox/central/namespace/service"
Expand Down Expand Up @@ -643,7 +644,8 @@ func startGRPCServer() {
config.StreamInterceptors = append(config.StreamInterceptors, ri.StreamServerInterceptor())

c := phonehomeClient.Singleton()
ri.Add("telemetry", c.GetRequestHandler())
c.SetInterceptor(ri)
api_requests.SetInterceptor(ri)

server := pkgGRPC.NewAPI(config)
server.Register(servicesToRegister()...)
Expand Down
68 changes: 68 additions & 0 deletions central/metrics/custom/api_requests/interceptor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package api_requests

import (
"context"
"time"

"github.com/stackrox/rox/pkg/clientprofile"
"github.com/stackrox/rox/pkg/eventual"
"github.com/stackrox/rox/pkg/grpc/common/requestinterceptor"
"github.com/stackrox/rox/pkg/logging"
)

var log = logging.LoggerForModule()

const handlerName = "api_requests"

var interceptor = eventual.New(
eventual.WithType[*requestinterceptor.RequestInterceptor]().
WithTimeout(5 * time.Minute).
WithContextCallback(func(ctx context.Context) {
log.Error("Request interceptor not provided within timeout; API request metrics will not be collected")
}),
)

// SetInterceptor provides the RequestInterceptor.
func SetInterceptor(ri *requestinterceptor.RequestInterceptor) {
interceptor.Set(ri)
}

// RegisterHandler adds or removes the RecordRequest handler on the
// RequestInterceptor based on whether any profile tracker is currently enabled.
// Blocks until the interceptor is provided via SetInterceptor, or the timeout
// expires. This is safe because runner.initialize runs in its own goroutine.
// Call this after reconfiguring trackers.
func RegisterHandler() {
ri := interceptor.Get()
if ri == nil {
return
}
for _, t := range profileTrackers {
if t.IsEnabled() {
ri.Add(handlerName, recordRequest)
log.Info("API request metrics handler registered")
return
}
}
ri.Remove(handlerName)
log.Info("API request metrics handler removed (no enabled profile trackers)")
}

// recordRequest matches the request against all profiles and increments every
// matching tracker. If no profile matches, the unknown tracker is incremented.
func recordRequest(rp *requestinterceptor.RequestParams) {
matched := false
for name, ruleset := range builtinProfiles {
if ruleset.CountMatched(rp, func(*clientprofile.Rule, clientprofile.Headers) {}) > 0 {
if t, ok := profileTrackers[name]; ok {
t.IncrementCounter(rp)
matched = true
}
}
}
if !matched {
if t, ok := profileTrackers[unknownProfile]; ok {
t.IncrementCounter(rp)
}
}
}
43 changes: 43 additions & 0 deletions central/metrics/custom/api_requests/labels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package api_requests

import (
"strconv"
"strings"

"github.com/stackrox/rox/central/metrics/custom/tracker"
"github.com/stackrox/rox/pkg/grpc/common/requestinterceptor"
)

type finding = requestinterceptor.RequestParams

// commonLabels are shared across all profile trackers.
var commonLabels = tracker.LazyLabelGetters[*finding]{
"UserID": func(f *finding) string {
if f.UserID != nil {
return f.UserID.UID()
}
return ""
},
"Path": func(f *finding) string { return f.Path },
"Method": func(f *finding) string { return f.Method },
"Status": func(f *finding) string { return strconv.Itoa(f.Code) },
}

// headerToLabel converts an HTTP header name to a valid Prometheus label name
// by removing hyphens. E.g. "Rh-Servicenow-Instance" -> "RhServicenowInstance".
func headerToLabel(header string) tracker.Label {
return tracker.Label(strings.ReplaceAll(header, "-", ""))
}

// makeHeaderGetter creates a getter that finds the request header whose
// hyphen-stripped name matches the label and returns its value.
func makeHeaderGetter(label tracker.Label) tracker.Getter[*finding] {
return func(f *finding) string {
for h := range f.Headers {
if headerToLabel(h) == label {
return strings.Join(f.Headers.Values(h), "; ")
}
}
return ""
}
}
39 changes: 39 additions & 0 deletions central/metrics/custom/api_requests/profiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package api_requests

import (
"github.com/stackrox/rox/pkg/clientprofile"
)

const unknownProfile = "unknown"

// builtinProfiles maps profile name to its matching criteria. A request can
// match multiple profiles.
var builtinProfiles = map[string]clientprofile.RuleSet{
"servicenow": {{
Headers: clientprofile.GlobMap{
"User-Agent": "*ServiceNow*",
"Rh-Servicenow-Instance": clientprofile.NoHeaderOrAnyValue,
},
}},
"splunk_ta": {
clientprofile.PathPattern("/api/splunk/ta/*"),
},
"roxctl": {{
Headers: clientprofile.GlobMap{
"User-Agent": "roxctl/*",
"Rh-Roxctl-Command": clientprofile.NoHeaderOrAnyValue,
"Rh-Roxctl-Command-Index": clientprofile.NoHeaderOrAnyValue,
},
}},
"sensor": {
clientprofile.HeaderPattern("User-Agent", "Rox Sensor/*"),
},
}

func init() {
for _, criteria := range builtinProfiles {
if err := criteria.Compile(); err != nil {
panic(err)
}
}
}
131 changes: 131 additions & 0 deletions central/metrics/custom/api_requests/tracker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package api_requests

import (
"iter"
"maps"
"strings"

"github.com/stackrox/rox/central/metrics/custom/tracker"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/clientprofile"
"github.com/stackrox/rox/pkg/glob"
)

// profileTracker wraps a TrackerBase with api_requests-specific concerns:
// metrics filtering by profile name and dynamic label resolution via glob
// patterns.
type profileTracker struct {
*tracker.TrackerBase[*finding]
profileName string
profile clientprofile.RuleSet
}

func (pt *profileTracker) NewConfiguration(cfg *storage.PrometheusMetrics_Group) (*tracker.Configuration, error) {
return pt.TrackerBase.NewConfiguration(filterMetrics(cfg, pt.profileName))
}

func (pt *profileTracker) Reconfigure(cfg *tracker.Configuration) {
pt.TrackerBase.ResetGetters(resolveGetters(cfg.GetMetrics(), pt.profile))
pt.TrackerBase.Reconfigure(cfg)
}

// matchesProfileHeader returns true if the label matches any header name
// pattern in the profile (after hyphen-stripping).
func matchesProfileHeader(label tracker.Label, profile clientprofile.RuleSet) bool {
for _, rule := range profile {
for header := range rule.Headers {
p := glob.Pattern(headerToLabel(header))
if p.Match(string(label)) {
return true
}
}
}
return false
}

// resolveGetters builds a getters map from common labels and resolved header
// patterns for the given metric descriptors.
func resolveGetters(metrics tracker.MetricDescriptors, profile clientprofile.RuleSet) tracker.LazyLabelGetters[*finding] {
getters := maps.Clone(commonLabels)
for _, labels := range metrics {
for _, label := range labels {
if _, isCommon := commonLabels[label]; isCommon {
continue
}
if matchesProfileHeader(label, profile) {
getters[label] = makeHeaderGetter(label)
}
}
}
return getters
}

// filterMetrics returns a copy of the group config containing only
// descriptors whose key starts with the given prefix.
func filterMetrics(cfg *storage.PrometheusMetrics_Group, prefix string) *storage.PrometheusMetrics_Group {
if prefix == "" {
return cfg
}
filtered := &storage.PrometheusMetrics_Group{
Enabled: cfg.GetEnabled(),
GatheringPeriodMinutes: cfg.GetGatheringPeriodMinutes(),
Descriptors: make(map[string]*storage.PrometheusMetrics_Group_Labels),
}
for key, labels := range cfg.GetDescriptors() {
if strings.HasPrefix(key, prefix) {
filtered.Descriptors[key] = labels
}
}
return filtered
}

// profileTrackers maps profile name to its tracker.
var profileTrackers = func() map[string]*profileTracker {
allProfiles := make(map[string]clientprofile.RuleSet, len(builtinProfiles)+1)
maps.Copy(allProfiles, builtinProfiles)
allProfiles[unknownProfile] = clientprofile.RuleSet{
clientprofile.HeaderPattern("User-Agent", clientprofile.NoHeaderOrAnyValue),
}

trackers := make(map[string]*profileTracker, len(allProfiles))
for name, profile := range allProfiles {
pt := &profileTracker{
TrackerBase: tracker.MakeGlobalTrackerBase(
"api_request",
"API requests from "+name,
maps.Clone(commonLabels),
nil,
),
profileName: name,
profile: profile,
}
pt.KnownLabels = func(descriptors map[string]*storage.PrometheusMetrics_Group_Labels) []tracker.Label {
labels := commonLabels.Labels()
for _, desc := range descriptors {
for _, label := range desc.GetLabels() {
if matchesProfileHeader(tracker.Label(label), pt.profile) {
labels = append(labels, tracker.Label(label))
}
}
}
return labels
}
trackers[name] = pt
}
return trackers
}()

// Trackers returns all profile trackers for registration with the runner.
func Trackers() iter.Seq[*tracker.Registration] {
return func(yield func(*tracker.Registration) bool) {
for _, pt := range profileTrackers {
reg := &tracker.Registration{
Tracker: pt,
GetGroupConfig: (*storage.PrometheusMetrics).GetApiRequests,
}
if !yield(reg) {
break
}
}
}
}
Loading
Loading