queryanalysis

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 4, 2026 License: MIT Imports: 8 Imported by: 0

README

gqlgen-query-analyzer

CI Go Reference Go Report Card

A standalone, dependency-light gqlgen handler extension that statically analyzes every GraphQL operation's AST and reports three numbers — depth, field count, and resolver count — 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, this package only reports; it does not return an error, and it does not need any resolver, schema, or existing gqlgen wiring to change.

Features
  • Query complexity estimation — depth, field count, and resolver-call count in one pass
  • Static AST traversal — no schema lookup, no resolver execution, no runtime data needed
  • Resolver-call estimation — distinguishes a free scalar read from an actual backend/DB/gRPC call
  • Prometheus metrics (histograms + a threshold-exceeded counter)
  • OpenTelemetry span attributes, including when nested inside another extension's span
  • Optional response.extensions.queryAnalysis output for ad hoc debugging
  • Drop-in gqlgen handler extension — a single Server.Use(...) call, no codegen changes
  • Observability-first, never rejects — pairs with extension.ComplexityLimit for enforcement

The three metrics

Given this schema and query:

type Salesman {
  fullname: String!
}

type Order {
  id: ID!
  total: Float!
  salesman: Salesman!
}

type Query {
  orders: [Order!]!
}
{
  orders {
    id
    total
    salesman {
      fullname
    }
  }
}
Metric Value Why
Depth 3 deepest chain: orders → salesman → fullname
FieldCount 5 orders, id, total, salesman, fullname
ResolverCount 2 only orders and salesman carry a sub-selection
Why ResolverCount is the interesting one

FieldCount counts every selected field, the same way naive complexity scoring does. But not every field is equally expensive: fullname inside salesman { fullname } is a free property read on the Salesman object that salesman already fetched — it does not trigger a separate resolver, DB query, gRPC call, or DataLoader batch. In GraphQL, only a field whose type is an Object, Interface, or Union can carry a sub-selection, and that is a purely syntactic fact about the query text — no schema lookup is needed to determine it. ResolverCount counts exactly those fields, giving you an estimate of how many distinct backend calls an operation will trigger, as opposed to how many scalars it returns.

This is a static, AST-only estimate. A field appears once in the query text no matter how many items its list actually returns at runtime, so ResolverCount never scales with runtime list cardinality. For example, orders { salesman { fullname } } counts salesman once whether orders resolves to 1, 10, or 1000 items — this package does not look at pagination arguments (first, last, etc.) or any other runtime data to estimate real fetch cost. If you need that, you need a runtime complexity estimator, not this package.

Requirements

  • Go 1.23+
  • gqlgen v0.17+

Install

go get github.com/duc2407/gqlgen-query-analyzer

Usage

Drop this into whatever file already calls handler.NewDefaultServer (or handler.New) with your gqlgen-generated ExecutableSchema — nothing else in your server setup changes:

package main

import (
	"net/http"

	"github.com/99designs/gqlgen/graphql/handler"
	queryanalysis "github.com/duc2407/gqlgen-query-analyzer"

	"yourmodule/graph" // the package gqlgen generated for you
)

func main() {
	srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{}}))

	srv.Use(queryanalysis.New(queryanalysis.Config{
		MaxDepth:         10,
		MaxFieldCount:    200,
		MaxResolverCount: 50,
		ExposeInResponse: true, // optional: surface stats in the response for debugging
	}))

	http.Handle("/query", srv)
	http.ListenAndServe(":8080", nil)
}

That's it — no resolver, schema, or existing gqlgen wiring needs to change. gqlgen already validates the query against your schema before this extension runs, so you don't need to call any validation yourself; MutateOperationContext only ever sees a document that already parsed and validated successfully.

If you also use an extension that creates its own tracing span per operation (e.g. otelgqlgen), register that extension's Server.Use(...) call before this one. gqlgen runs ResponseInterceptors outermost-first in registration order, so registering this extension later makes its InterceptResponse run nested inside that span, letting it attach graphql.query.* attributes to it:

srv.Use(otelgqlgen.Middleware()) // registered first -> outermost -> creates the span
srv.Use(queryanalysis.New(cfg))  // registered second -> runs nested inside that span
Config
Field Type Default Meaning
MaxDepth int 0 (no limit) Warn when selection depth exceeds this value.
MaxFieldCount int 0 (no limit) Warn when total field count exceeds this value.
MaxResolverCount int 0 (no limit) Warn when resolver call count exceeds this value.
ExposeInResponse bool false Attach Stats to response.extensions.queryAnalysis.
Registerer prometheus.Registerer prometheus.DefaultRegisterer Where Prometheus metrics are registered. Never forced to the global default; pass your own for isolation in tests or multi-tenant apps.

<= 0 means "no limit for this dimension," not "limit of zero." There is no Enabled flag: if you don't want this extension running, simply don't call Server.Use(New(cfg)).

Observability
  • Prometheus (registered on Config.Registerer):
    • graphql_query_depth (histogram, label operation_type)
    • graphql_query_field_count (histogram, label operation_type)
    • graphql_query_resolver_count (histogram, label operation_type)
    • graphql_query_threshold_exceeded_total (counter, labels operation_type, dimension)
  • OpenTelemetry span attributes, set on the span active in the request context (if any, and only if it is recording): graphql.operation.name, graphql.operation.type, graphql.query.depth, graphql.query.field_count, graphql.query.resolver_count, graphql.query.threshold_exceeded.
  • GraphQL response, when ExposeInResponse is true: response.extensions.queryAnalysis holds the same Stats struct as JSON — operationName, operationType, depth, fieldCount, resolverCount, and warning (omitted when nothing was exceeded, otherwise a single string naming every exceeded dimension, e.g. "exceeds configured limits: depth, resolverCount").

This package does not log anything. If you want to see warnings in logs, read Stats.Warning from wherever you already have request-scoped logging (a custom ResponseInterceptor, a middleware, etc.) — this package deliberately has no logging dependency or code path.

Why not extension.ComplexityLimit or oyyblin/gqlgen-depth-limit-extension?

Solution Checks Strengths Trade-offs
extension.ComplexityLimit (gqlgen) Weighted query complexity Built-in to gqlgen, supports a custom complexity per field You must declare and maintain a complexity value for every field — a missed or wrong value silently under- or over-counts
oyyblin/gqlgen-depth-limit-extension Query depth only Very simple, stops runaway recursive queries Only sees depth — can't tell a cheap field from an expensive one
gqlgen-query-analyzer (this package) AST depth + field count + resolver count Zero-config: no per-field annotations to maintain, automatically separates a real resolver call from a free scalar read No weighted cost, no visibility into pagination arguments or actual record counts, and never rejects on its own — pair it with extension.ComplexityLimit if you need enforcement

Both extension.ComplexityLimit and oyyblin/gqlgen-depth-limit-extension are single-metric and reject-only: they return a *gqlerror.Error and abort the request once a threshold is exceeded. Neither distinguishes a free scalar read from an actual resolver call, and neither is designed for observability — they're designed to reject.

This package fills a different gap: multi-metric (depth + field count + resolver count), non-rejecting, observability-first analysis, meant to run alongside a complexity limiter (or on its own) so you can see what your traffic actually looks like before deciding where to draw hard limits.

Performance

BenchmarkWalkSelectionSet-8   	21471409	       109.3 ns/op	       0 B/op	       0 allocs/op

Measured on a synthetic query with ~500 fields and a selection depth of 20 (Apple M2). The walker does a single recursive pass over the already-parsed AST with no allocations.

Testing

go test ./... -race -coverpkg=./... -coverprofile=cover.out
go tool cover -func=cover.out | tail -1

Current coverage: 95.0%. Tests are split by kind:

  • Root package (walk_test.go, analyzer_test.go, example_test.go): table-driven unit tests for the AST walker directly against gqlparser (no gqlgen server needed) — flat queries, multi-level nesting, fragment spreads and inline fragments (both transparent to depth), __typename exclusion, and the resolver-vs-field-count distinction — plus a benchmark for the walker and a runnable Example showing the entire integration in a single Server.Use call.
  • e2e/: black-box tests that drive real requests through a real gqlgen handler.Server (hand-rolled ExecutableSchema, no code generation) via gqlgen's own test client, asserting:
    • the response is never rejected even when every threshold is exceeded, and each dimension's threshold is checked independently;
    • Prometheus counters and histograms move by the expected amount after a real request, and that registering against the same Registerer twice does not panic;
    • OpenTelemetry span attributes are set correctly, including when nested inside another extension's span.

Non-goals

  • Not a complexity-limiting / request-rejecting library — see extension.ComplexityLimit for that.
  • Not a general-purpose GraphQL AST utility library.
  • No logging dependency or logging code path.
  • No attempt to estimate real fetch cost from runtime list cardinality (e.g. pagination first arguments) — this is a static, AST-only estimate.

License

MIT, see LICENSE.

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

Examples

Constants

View Source
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

func New(cfg Config) *Analyzer

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

func (a *Analyzer) ExtensionName() string

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.

func (*Analyzer) Validate

func (a *Analyzer) Validate(_ graphql.ExecutableSchema) error

Validate implements graphql.HandlerExtension. This extension has no schema-specific requirements.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL