Skip to content

Commit fbe9efb

Browse files
feat(governance): add query metrics
Emit unsampled metrics for the governance query endpoint: requests{namespace,direction,all_org_features}, customers_not_found{namespace}, and feature_access / customer_keys histograms{namespace}. namespace is a per-tenant label (consistent with ingest/sink/balanceworker); per-request counts are histogram values to bound cardinality. Meter injected via Config like the tracer.
1 parent b3ebdaf commit fbe9efb

6 files changed

Lines changed: 110 additions & 4 deletions

File tree

api/v3/handlers/governance/query.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ func (h *handler) QueryGovernanceAccess() QueryGovernanceAccessHandler {
2828
return httptransport.NewHandlerWithArgs(
2929
func(ctx context.Context, r *http.Request, params QueryGovernanceAccessParams) (governance.QueryAccessInput, error) {
3030
ns, err := h.resolveNamespace(ctx)
31-
3231
if err != nil {
3332
return governance.QueryAccessInput{}, err
3433
}
@@ -61,7 +60,6 @@ func (h *handler) QueryGovernanceAccess() QueryGovernanceAccessHandler {
6160
},
6261
func(ctx context.Context, input governance.QueryAccessInput) (QueryGovernanceAccessResponse, error) {
6362
res, err := h.governanceService.QueryAccess(ctx, input)
64-
6563
if err != nil {
6664
return QueryGovernanceAccessResponse{}, err
6765
}
@@ -130,7 +128,6 @@ func applyPaging(ctx context.Context, input *governance.QueryAccessInput, params
130128

131129
func decodeCursorParam(ctx context.Context, field, raw string) (*pagination.Cursor, error) {
132130
cursor, err := pagination.DecodeCursor(raw)
133-
134131
if err != nil {
135132
return nil, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{{
136133
Field: field,

app/common/governance.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package common
22

33
import (
44
"github.com/google/wire"
5+
"go.opentelemetry.io/otel/metric"
56
"go.opentelemetry.io/otel/trace"
67

78
"github.com/openmeterio/openmeter/openmeter/customer"
@@ -18,11 +19,13 @@ func NewGovernanceService(
1819
customer customer.Service,
1920
entitlementRegistry *registry.Entitlement,
2021
tracer trace.Tracer,
22+
meter metric.Meter,
2123
) (governance.Service, error) {
2224
return governanceservice.New(governanceservice.Config{
2325
Customer: customer,
2426
Entitlement: entitlementRegistry.Entitlement,
2527
Feature: entitlementRegistry.Feature,
2628
Tracer: tracer,
29+
Meter: meter,
2730
})
2831
}

cmd/server/wire_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openmeter/governance/service/service.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/samber/lo"
1111
"go.opentelemetry.io/otel/attribute"
12+
"go.opentelemetry.io/otel/metric"
1213
"go.opentelemetry.io/otel/trace"
1314

1415
"github.com/openmeterio/openmeter/openmeter/customer"
@@ -31,6 +32,7 @@ type Config struct {
3132
Entitlement entitlement.Service
3233
Feature feature.FeatureConnector
3334
Tracer trace.Tracer
35+
Meter metric.Meter
3436
}
3537

3638
func (c Config) Validate() error {
@@ -52,6 +54,10 @@ func (c Config) Validate() error {
5254
errs = append(errs, errors.New("tracer is required"))
5355
}
5456

57+
if c.Meter == nil {
58+
errs = append(errs, errors.New("meter is required"))
59+
}
60+
5561
return errors.Join(errs...)
5662
}
5763

@@ -60,11 +66,17 @@ func New(config Config) (governance.Service, error) {
6066
return nil, err
6167
}
6268

69+
metrics, err := newMetrics(config.Meter)
70+
if err != nil {
71+
return nil, err
72+
}
73+
6374
return &service{
6475
customerService: config.Customer,
6576
entitlementService: config.Entitlement,
6677
featureConnector: config.Feature,
6778
tracer: config.Tracer,
79+
metrics: metrics,
6880
}, nil
6981
}
7082

@@ -73,6 +85,70 @@ type service struct {
7385
entitlementService entitlement.Service
7486
featureConnector feature.FeatureConnector
7587
tracer trace.Tracer
88+
metrics queryMetrics
89+
}
90+
91+
// queryMetrics holds the instruments for the governance query endpoint. These are unsampled
92+
// (unlike spans), so they back SLOs, alerting, and capacity dashboards. Per-request counts
93+
// are recorded as histogram observations rather than attributes to keep series cardinality
94+
// bounded; only low-cardinality enums are used as counter attributes.
95+
type queryMetrics struct {
96+
// requests counts queries, broken down by pagination direction and whether the
97+
// all-org-features (no filter) path was taken.
98+
requests metric.Int64Counter
99+
// customersNotFound counts input keys that did not resolve to a customer. These return
100+
// HTTP 200 with a partial error, so they are invisible to HTTP-level metrics.
101+
customersNotFound metric.Int64Counter
102+
// featureAccess records the number of feature evaluations per query — the work unit that
103+
// drives latency (~customers × features).
104+
featureAccess metric.Int64Histogram
105+
// customerKeys records the number of customer keys requested per query.
106+
customerKeys metric.Int64Histogram
107+
}
108+
109+
func newMetrics(meter metric.Meter) (queryMetrics, error) {
110+
requests, err := meter.Int64Counter(
111+
"openmeter.governance.query.requests",
112+
metric.WithDescription("Number of governance access queries"),
113+
metric.WithUnit("{request}"),
114+
)
115+
if err != nil {
116+
return queryMetrics{}, fmt.Errorf("failed to create requests counter: %w", err)
117+
}
118+
119+
customersNotFound, err := meter.Int64Counter(
120+
"openmeter.governance.query.customers_not_found",
121+
metric.WithDescription("Number of customer keys that did not resolve to a customer"),
122+
metric.WithUnit("{customer}"),
123+
)
124+
if err != nil {
125+
return queryMetrics{}, fmt.Errorf("failed to create customers_not_found counter: %w", err)
126+
}
127+
128+
featureAccess, err := meter.Int64Histogram(
129+
"openmeter.governance.query.feature_access",
130+
metric.WithDescription("Number of feature evaluations per governance query"),
131+
metric.WithUnit("{evaluation}"),
132+
)
133+
if err != nil {
134+
return queryMetrics{}, fmt.Errorf("failed to create feature_access histogram: %w", err)
135+
}
136+
137+
customerKeys, err := meter.Int64Histogram(
138+
"openmeter.governance.query.customer_keys",
139+
metric.WithDescription("Number of customer keys requested per governance query"),
140+
metric.WithUnit("{key}"),
141+
)
142+
if err != nil {
143+
return queryMetrics{}, fmt.Errorf("failed to create customer_keys histogram: %w", err)
144+
}
145+
146+
return queryMetrics{
147+
requests: requests,
148+
customersNotFound: customersNotFound,
149+
featureAccess: featureAccess,
150+
customerKeys: customerKeys,
151+
}, nil
76152
}
77153

78154
var _ governance.Service = (*service)(nil)
@@ -131,12 +207,38 @@ func (s *service) QueryAccess(ctx context.Context, input governance.QueryAccessI
131207
out.Last = lo.ToPtr(cursorFor(sortedCustomers[len(sortedCustomers)-1]))
132208
}
133209

210+
s.recordQueryMetrics(ctx, input, out, len(customers.queryErrors))
211+
134212
return out, nil
135213
}
136214

137215
return tracex.Start[governance.QueryResult](ctx, s.tracer, "governance.QueryAccess").Wrap(fn)
138216
}
139217

218+
// recordQueryMetrics emits the unsampled query metrics. namespace is a per-tenant label
219+
// (consistent with ingest/sink/balanceworker); per-request counts are histogram values, and
220+
// only low-cardinality enums (direction, all_org_features) are counter attributes.
221+
func (s *service) recordQueryMetrics(ctx context.Context, input governance.QueryAccessInput, out governance.QueryResult, notFound int) {
222+
namespaceAttr := attribute.String("namespace", input.Namespace)
223+
224+
s.metrics.requests.Add(ctx, 1, metric.WithAttributes(
225+
namespaceAttr,
226+
attribute.String("direction", paginationDirection(input)),
227+
attribute.Bool("all_org_features", len(input.FeatureKeys) == 0),
228+
))
229+
230+
if notFound > 0 {
231+
s.metrics.customersNotFound.Add(ctx, int64(notFound), metric.WithAttributes(namespaceAttr))
232+
}
233+
234+
featureAccessTotal := lo.SumBy(out.Customers, func(c governance.CustomerAccess) int {
235+
return len(c.Features)
236+
})
237+
238+
s.metrics.featureAccess.Record(ctx, int64(featureAccessTotal), metric.WithAttributes(namespaceAttr))
239+
s.metrics.customerKeys.Record(ctx, int64(len(input.CustomerKeys)), metric.WithAttributes(namespaceAttr))
240+
}
241+
140242
// resolvedCustomer groups the matched input keys for a single customer.
141243
type resolvedCustomer struct {
142244
customer customer.Customer

openmeter/governance/service/service_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/samber/lo"
1111
"github.com/stretchr/testify/assert"
1212
"github.com/stretchr/testify/require"
13+
metricnoop "go.opentelemetry.io/otel/metric/noop"
1314
"go.opentelemetry.io/otel/trace/noop"
1415

1516
"github.com/openmeterio/openmeter/app/config"
@@ -140,6 +141,7 @@ func newTestService(t *testing.T, deps *testDeps) governance.Service {
140141
Entitlement: deps.registry.Entitlement,
141142
Feature: deps.registry.Feature,
142143
Tracer: noop.NewTracerProvider().Tracer("test"),
144+
Meter: metricnoop.NewMeterProvider().Meter("test"),
143145
})
144146
require.NoError(t, err)
145147

openmeter/server/server_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/samber/lo"
1818
"github.com/stretchr/testify/assert"
1919
"github.com/stretchr/testify/require"
20+
metricnoop "go.opentelemetry.io/otel/metric/noop"
2021
"go.opentelemetry.io/otel/trace/noop"
2122

2223
"github.com/openmeterio/openmeter/api"
@@ -751,6 +752,7 @@ func getTestServer(t *testing.T, opts ...func(*router.Config)) (*Server, *MockSt
751752
Entitlement: &NoopEntitlementConnector{},
752753
Feature: featureService,
753754
Tracer: noop.NewTracerProvider().Tracer("test"),
755+
Meter: metricnoop.NewMeterProvider().Meter("test"),
754756
})
755757
assert.NoError(t, err, "failed to create governance service")
756758

0 commit comments

Comments
 (0)