Skip to content

Commit c4968d6

Browse files
[FEATURE] [internal] adds metric collection for graphql queries and resolvers (#354)
* adds metric collection for graphql queries and resolvers * cleans up graphQL error classification and comments * fixes go import formatting
1 parent 5108a60 commit c4968d6

File tree

6 files changed

+197
-7
lines changed

6 files changed

+197
-7
lines changed

internal/metrics/metrics.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ type MetricsService interface {
3636
ObserveDBTransactionDuration(status string, duration float64)
3737
ObserveDBBatchSize(operation, table string, size int)
3838
IncSignatureVerificationExpired(expiredSeconds float64)
39+
// GraphQL Metrics
40+
ObserveGraphQLFieldDuration(operationName, fieldName string, duration float64)
41+
IncGraphQLField(operationName, fieldName string, success bool)
42+
ObserveGraphQLComplexity(operationName string, complexity int)
43+
IncGraphQLError(operationName, errorType string)
3944
}
4045

4146
// MetricsService handles all metrics for the wallet-backend
@@ -77,6 +82,12 @@ type metricsService struct {
7782

7883
// Signature Verification Metrics
7984
signatureVerificationExpired *prometheus.CounterVec
85+
86+
// GraphQL Metrics
87+
graphqlFieldDuration *prometheus.SummaryVec
88+
graphqlFieldsTotal *prometheus.CounterVec
89+
graphqlComplexity *prometheus.SummaryVec
90+
graphqlErrorsTotal *prometheus.CounterVec
8091
}
8192

8293
// NewMetricsService creates a new metrics service with all metrics registered
@@ -250,6 +261,38 @@ func NewMetricsService(db *sqlx.DB) MetricsService {
250261
[]string{"expired_seconds"},
251262
)
252263

264+
// GraphQL Metrics
265+
m.graphqlFieldDuration = prometheus.NewSummaryVec(
266+
prometheus.SummaryOpts{
267+
Name: "graphql_field_duration_seconds",
268+
Help: "Duration of GraphQL field resolver execution",
269+
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
270+
},
271+
[]string{"operation_name", "field_name"},
272+
)
273+
m.graphqlFieldsTotal = prometheus.NewCounterVec(
274+
prometheus.CounterOpts{
275+
Name: "graphql_fields_total",
276+
Help: "Total number of GraphQL field resolutions",
277+
},
278+
[]string{"operation_name", "field_name", "success"},
279+
)
280+
m.graphqlComplexity = prometheus.NewSummaryVec(
281+
prometheus.SummaryOpts{
282+
Name: "graphql_complexity",
283+
Help: "GraphQL query complexity values",
284+
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
285+
},
286+
[]string{"operation_name"},
287+
)
288+
m.graphqlErrorsTotal = prometheus.NewCounterVec(
289+
prometheus.CounterOpts{
290+
Name: "graphql_errors_total",
291+
Help: "Total number of GraphQL errors",
292+
},
293+
[]string{"operation_name", "error_type"},
294+
)
295+
253296
m.registerMetrics()
254297
return m
255298
}
@@ -279,6 +322,10 @@ func (m *metricsService) registerMetrics() {
279322
m.dbTxnDuration,
280323
m.dbBatchSize,
281324
m.signatureVerificationExpired,
325+
m.graphqlFieldDuration,
326+
m.graphqlFieldsTotal,
327+
m.graphqlComplexity,
328+
m.graphqlErrorsTotal,
282329
)
283330
}
284331

@@ -466,3 +513,24 @@ func (m *metricsService) ObserveDBBatchSize(operation, table string, size int) {
466513
func (m *metricsService) IncSignatureVerificationExpired(expiredSeconds float64) {
467514
m.signatureVerificationExpired.WithLabelValues(fmt.Sprintf("%fs", expiredSeconds)).Inc()
468515
}
516+
517+
// GraphQL Metrics
518+
func (m *metricsService) ObserveGraphQLFieldDuration(operationName, fieldName string, duration float64) {
519+
m.graphqlFieldDuration.WithLabelValues(operationName, fieldName).Observe(duration)
520+
}
521+
522+
func (m *metricsService) IncGraphQLField(operationName, fieldName string, success bool) {
523+
successStr := "true"
524+
if !success {
525+
successStr = "false"
526+
}
527+
m.graphqlFieldsTotal.WithLabelValues(operationName, fieldName, successStr).Inc()
528+
}
529+
530+
func (m *metricsService) ObserveGraphQLComplexity(operationName string, complexity int) {
531+
m.graphqlComplexity.WithLabelValues(operationName).Observe(float64(complexity))
532+
}
533+
534+
func (m *metricsService) IncGraphQLError(operationName, errorType string) {
535+
m.graphqlErrorsTotal.WithLabelValues(operationName, errorType).Inc()
536+
}

internal/metrics/mocks.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,19 @@ func (m *MockMetricsService) ObserveDBBatchSize(operation, table string, size in
112112
func (m *MockMetricsService) IncSignatureVerificationExpired(expiredSeconds float64) {
113113
m.Called(expiredSeconds)
114114
}
115+
116+
func (m *MockMetricsService) ObserveGraphQLFieldDuration(operationName, fieldName string, duration float64) {
117+
m.Called(operationName, fieldName, duration)
118+
}
119+
120+
func (m *MockMetricsService) IncGraphQLField(operationName, fieldName string, success bool) {
121+
m.Called(operationName, fieldName, success)
122+
}
123+
124+
func (m *MockMetricsService) ObserveGraphQLComplexity(operationName string, complexity int) {
125+
m.Called(operationName, complexity)
126+
}
127+
128+
func (m *MockMetricsService) IncGraphQLError(operationName, errorType string) {
129+
m.Called(operationName, errorType)
130+
}

internal/serve/graphql/resolvers/resolver.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/stellar/wallet-backend/internal/data"
2121
"github.com/stellar/wallet-backend/internal/entities"
2222
"github.com/stellar/wallet-backend/internal/indexer/types"
23+
"github.com/stellar/wallet-backend/internal/metrics"
2324
"github.com/stellar/wallet-backend/internal/serve/graphql/dataloaders"
2425
graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated"
2526
"github.com/stellar/wallet-backend/internal/serve/middleware"
@@ -41,17 +42,20 @@ type Resolver struct {
4142
transactionService services.TransactionService
4243
// feeBumpService provides fee-bump transaction wrapping operations
4344
feeBumpService services.FeeBumpService
45+
// metricsService provides metrics collection capabilities
46+
metricsService metrics.MetricsService
4447
}
4548

4649
// NewResolver creates a new resolver instance with required dependencies
4750
// This constructor is called during server startup to initialize the resolver
4851
// Dependencies are injected here and available to all resolver functions.
49-
func NewResolver(models *data.Models, accountService services.AccountService, transactionService services.TransactionService, feeBumpService services.FeeBumpService) *Resolver {
52+
func NewResolver(models *data.Models, accountService services.AccountService, transactionService services.TransactionService, feeBumpService services.FeeBumpService, metricsService metrics.MetricsService) *Resolver {
5053
return &Resolver{
5154
models: models,
5255
accountService: accountService,
5356
transactionService: transactionService,
5457
feeBumpService: feeBumpService,
58+
metricsService: metricsService,
5559
}
5660
}
5761

internal/serve/middleware/complexity_logger.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,25 @@ import (
66
"context"
77

88
"github.com/stellar/go/support/log"
9+
10+
"github.com/stellar/wallet-backend/internal/metrics"
911
)
1012

1113
// ComplexityLogger implements the gqlgen-complexity-reporter interface
1214
// to log GraphQL query complexity values for monitoring and debugging.
13-
type ComplexityLogger struct{}
15+
// It also emits Prometheus metrics for query complexity.
16+
type ComplexityLogger struct {
17+
metricsService metrics.MetricsService
18+
}
1419

1520
// NewComplexityLogger creates a new complexity logger instance.
16-
func NewComplexityLogger() *ComplexityLogger {
17-
return &ComplexityLogger{}
21+
func NewComplexityLogger(metricsService metrics.MetricsService) *ComplexityLogger {
22+
return &ComplexityLogger{
23+
metricsService: metricsService,
24+
}
1825
}
1926

20-
// ReportComplexity logs the complexity of a GraphQL query.
27+
// ReportComplexity logs the complexity of a GraphQL query and records it to Prometheus.
2128
// This method is called by the gqlgen-complexity-reporter extension.
2229
func (c *ComplexityLogger) ReportComplexity(ctx context.Context, operationName string, complexity int) {
2330
logger := log.Ctx(ctx)
@@ -29,4 +36,6 @@ func (c *ComplexityLogger) ReportComplexity(ctx context.Context, operationName s
2936
logger.WithField("operation_name", operationName).
3037
WithField("complexity", complexity).
3138
Info("graphql query complexity")
39+
40+
c.metricsService.ObserveGraphQLComplexity(operationName, complexity)
3241
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package middleware
2+
3+
import (
4+
"context"
5+
"errors"
6+
"time"
7+
8+
"github.com/99designs/gqlgen/graphql"
9+
"github.com/vektah/gqlparser/v2/gqlerror"
10+
11+
"github.com/stellar/wallet-backend/internal/metrics"
12+
)
13+
14+
// GraphQLFieldMetrics tracks metrics for individual GraphQL field resolvers.
15+
// It measures field execution duration, counts resolutions, and tracks errors.
16+
type GraphQLFieldMetrics struct {
17+
metricsService metrics.MetricsService
18+
}
19+
20+
// NewGraphQLFieldMetrics creates a new GraphQL field metrics middleware.
21+
func NewGraphQLFieldMetrics(metricsService metrics.MetricsService) *GraphQLFieldMetrics {
22+
return &GraphQLFieldMetrics{
23+
metricsService: metricsService,
24+
}
25+
}
26+
27+
// Middleware is the field middleware function that wraps field resolver execution.
28+
// It measures execution time and tracks success/failure for each field resolution.
29+
func (m *GraphQLFieldMetrics) Middleware(ctx context.Context, next graphql.Resolver) (interface{}, error) {
30+
// Get the field context to extract operation and field information
31+
fc := graphql.GetFieldContext(ctx)
32+
if fc == nil {
33+
// If we can't get field context, just pass through
34+
return next(ctx)
35+
}
36+
37+
oc := graphql.GetOperationContext(ctx)
38+
operationName := "<unnamed>"
39+
if oc != nil && oc.OperationName != "" {
40+
operationName = oc.OperationName
41+
}
42+
43+
fieldName := fc.Field.Name
44+
45+
startTime := time.Now()
46+
res, err := next(ctx)
47+
duration := time.Since(startTime).Seconds()
48+
m.metricsService.ObserveGraphQLFieldDuration(operationName, fieldName, duration)
49+
50+
success := err == nil
51+
m.metricsService.IncGraphQLField(operationName, fieldName, success)
52+
53+
if err != nil {
54+
errorType := classifyGraphQLError(err)
55+
m.metricsService.IncGraphQLError(operationName, errorType)
56+
}
57+
58+
return res, err
59+
}
60+
61+
// classifyGraphQLError determines the error type from a GraphQL error.
62+
// It inspects error extensions and error structure to categorize the error.
63+
func classifyGraphQLError(err error) string {
64+
var gqlErr *gqlerror.Error
65+
if errors.As(err, &gqlErr) {
66+
if gqlErr.Extensions != nil {
67+
if code, ok := gqlErr.Extensions["code"].(string); ok {
68+
switch code {
69+
case "GRAPHQL_VALIDATION_FAILED":
70+
return "validation_error"
71+
case "GRAPHQL_PARSE_FAILED":
72+
return "parse_error"
73+
case "BAD_USER_INPUT":
74+
return "bad_input"
75+
case "UNAUTHENTICATED":
76+
return "auth_error"
77+
case "FORBIDDEN":
78+
return "forbidden"
79+
case "INTERNAL_SERVER_ERROR":
80+
return "internal_error"
81+
default:
82+
return code
83+
}
84+
}
85+
}
86+
}
87+
88+
return "unknown"
89+
}

internal/serve/serve.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ func handler(deps handlerDeps) http.Handler {
231231
r.Route("/graphql", func(r chi.Router) {
232232
r.Use(middleware.DataloaderMiddleware(deps.Models))
233233

234-
resolver := resolvers.NewResolver(deps.Models, deps.AccountService, deps.TransactionService, deps.FeeBumpService)
234+
resolver := resolvers.NewResolver(deps.Models, deps.AccountService, deps.TransactionService, deps.FeeBumpService, deps.MetricsService)
235235

236236
config := generated.Config{
237237
Resolvers: resolver,
@@ -254,9 +254,13 @@ func handler(deps handlerDeps) http.Handler {
254254
srv.Use(extension.FixedComplexityLimit(deps.GraphQLComplexityLimit))
255255

256256
// Add complexity logging - reports all queries with their complexity values
257-
reporter := middleware.NewComplexityLogger()
257+
reporter := middleware.NewComplexityLogger(deps.MetricsService)
258258
srv.Use(complexityreporter.NewExtension(reporter))
259259

260+
// Add field-level metrics tracking
261+
fieldMetrics := middleware.NewGraphQLFieldMetrics(deps.MetricsService)
262+
srv.AroundFields(fieldMetrics.Middleware)
263+
260264
r.Handle("/query", srv)
261265
})
262266
})

0 commit comments

Comments
 (0)