Documentation
¶
Overview ¶
Package queryanalysis is a standalone gqlgen handler extension that statically analyzes every GraphQL operation's AST — selection depth, total field count, and estimated resolver-call count — and reports the result via Prometheus metrics, OpenTelemetry span attributes, and (optionally) the GraphQL response's extensions field.
It never rejects a request, even when a configured threshold is exceeded; it only reports. See extension.ComplexityLimit if you need a rejecting complexity limiter instead.
Index ¶
- Constants
- type Analyzer
- func (a *Analyzer) ExtensionName() string
- func (a *Analyzer) InterceptResponse(ctx context.Context, next graphql.ResponseHandler) *graphql.Response
- func (a *Analyzer) MutateOperationContext(_ context.Context, opCtx *graphql.OperationContext) *gqlerror.Error
- func (a *Analyzer) Validate(_ graphql.ExecutableSchema) error
- type Config
- type Stats
Examples ¶
Constants ¶
const ExtensionName = "QueryAnalysis"
ExtensionName is the name this extension registers itself under, and the key used for graphql.OperationContext.Stats.SetExtension / GetExtension.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Analyzer ¶
type Analyzer struct {
// contains filtered or unexported fields
}
Analyzer is a gqlgen handler extension implementing graphql.HandlerExtension, graphql.OperationContextMutator, and graphql.ResponseInterceptor. Construct one with New and register it with Server.Use.
func New ¶
New builds an Analyzer from cfg and registers its Prometheus metrics on cfg.Registerer (prometheus.DefaultRegisterer if nil). Registering the same Registerer multiple times (e.g. calling New more than once in tests) is safe: existing collectors are reused rather than causing a duplicate-registration error.
Example ¶
This example wires the Analyzer into an existing gqlgen server with a single Server.Use call. No resolver, schema, or existing gqlgen wiring needs to change to use it.
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/99designs/gqlgen/client"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/prometheus/client_golang/prometheus"
"github.com/vektah/gqlparser/v2"
"github.com/vektah/gqlparser/v2/ast"
queryanalysis "github.com/duc2407/gqlgen-query-analyzer"
)
func main() {
schema := gqlparser.MustLoadSchema(&ast.Source{Name: "demo.graphql", Input: `
type Salesman {
fullname: String!
}
type Order {
id: ID!
total: Float!
salesman: Salesman!
}
type Query {
orders: [Order!]!
}
`})
es := &graphql.ExecutableSchemaMock{
SchemaFunc: func() *ast.Schema { return schema },
ComplexityFunc: func(_ context.Context, _, _ string, _ int, _ map[string]any) (int, bool) {
return 0, false
},
ExecFunc: func(_ context.Context) graphql.ResponseHandler {
data := `{"orders":[{"id":"1","total":42.5,"salesman":{"fullname":"Jane Doe"}}]}`
return graphql.OneShot(&graphql.Response{Data: json.RawMessage(data)})
},
}
srv := handler.NewDefaultServer(es)
srv.Use(queryanalysis.New(queryanalysis.Config{
MaxDepth: 5,
MaxFieldCount: 50,
MaxResolverCount: 20,
ExposeInResponse: true,
Registerer: prometheus.NewRegistry(),
}))
c := client.New(srv)
resp, err := c.RawPost(`{ orders { id total salesman { fullname } } }`)
if err != nil {
fmt.Println("error:", err)
return
}
stats := resp.Extensions["queryAnalysis"].(map[string]any)
fmt.Println("depth:", int(stats["depth"].(float64)))
fmt.Println("fieldCount:", int(stats["fieldCount"].(float64)))
fmt.Println("resolverCount:", int(stats["resolverCount"].(float64)))
}
Output: depth: 3 fieldCount: 5 resolverCount: 2
func (*Analyzer) ExtensionName ¶
ExtensionName implements graphql.HandlerExtension.
func (*Analyzer) InterceptResponse ¶
func (a *Analyzer) InterceptResponse(ctx context.Context, next graphql.ResponseHandler) *graphql.Response
InterceptResponse implements graphql.ResponseInterceptor. It runs the rest of the response pipeline first, then reports the Stats computed by MutateOperationContext as OpenTelemetry span attributes on the span active in ctx (if any is recording) and, if Config.ExposeInResponse is set, attaches them to the response's extensions field.
If used alongside an extension that creates its own tracing span per operation (e.g. github.com/ravilushqa/otelgqlgen), register that extension's Server.Use(...) call before this one: gqlgen runs extensions outermost-first for responses, so registering this extension later makes its InterceptResponse run nested inside that span.
func (*Analyzer) MutateOperationContext ¶
func (a *Analyzer) MutateOperationContext(_ context.Context, opCtx *graphql.OperationContext) *gqlerror.Error
MutateOperationContext implements graphql.OperationContextMutator. It walks the already-parsed-and-validated operation AST, computes Stats, and stashes them on opCtx.Stats for InterceptResponse to read back later. It always returns nil: this extension never rejects a request.
type Config ¶
type Config struct {
// MaxDepth is the maximum allowed selection depth. <= 0 means no limit.
MaxDepth int
// MaxFieldCount is the maximum allowed total field count. <= 0 means no limit.
MaxFieldCount int
// MaxResolverCount is the maximum allowed resolver call count. <= 0 means no limit.
MaxResolverCount int
// ExposeInResponse, when true, attaches the computed Stats to the
// GraphQL response's extensions["queryAnalysis"] field.
ExposeInResponse bool
// Registerer is the Prometheus registerer metrics are registered on.
// Defaults to prometheus.DefaultRegisterer when nil.
Registerer prometheus.Registerer
}
Config configures an Analyzer. All threshold fields default to "no limit" when left at zero or negative.
type Stats ¶
type Stats struct {
OperationName string `json:"operationName"`
OperationType string `json:"operationType"`
// Depth is the length of the deepest chain of nested fields in the
// operation's selection set. Fragment spreads and inline fragments are
// transparent: they do not add a depth level themselves, only the
// fields inside them do.
Depth int `json:"depth"`
// FieldCount is the total number of selected fields after fragment
// expansion, including leaf scalar fields. __typename is excluded.
FieldCount int `json:"fieldCount"`
// ResolverCount is the number of fields that carry a non-empty
// selection set, i.e. fields that (in a real schema) can only be
// Object/Interface/Union typed and therefore represent a distinct
// resolver call rather than a free property read on an already
// fetched object. This is a purely static, AST-shape estimate: a
// field appears once in the query text regardless of how many items
// its list actually returns at runtime, so ResolverCount never scales
// with runtime list cardinality.
ResolverCount int `json:"resolverCount"`
// Warning names every configured dimension the operation exceeded,
// e.g. "exceeds configured limits: depth, resolverCount". Empty when
// no threshold was exceeded.
Warning string `json:"warning,omitempty"`
}
Stats holds the static analysis result for a single GraphQL operation.