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
3638func (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
78154var _ 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.
141243type resolvedCustomer struct {
142244 customer customer.Customer
0 commit comments