Custom plugin
Implement a small plugin that adds a request id and shows where each lifecycle hook runs.
Plugins are the right tool for cross-cutting concerns: logging, tracing, metrics, request-id propagation, auth checks. This guide implements a small request-id plugin to show the lifecycle in action.
What we're building
- Generate a UUID at the start of every request.
- Attach it to the
context.Contextso resolvers can read it. - Add it to the response
extensionsblock so clients can correlate. - Echo it on errors for log scraping.
The plugin
package extensions
import (
"context"
"crypto/rand"
"encoding/hex"
"github.com/grx-gql/grx/core"
"github.com/grx-gql/grx/plugins"
)
type requestIDKey struct{}
func RequestIDFrom(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey{}).(string); ok {
return v
}
return ""
}
type RequestID struct{ plugins.Base }
func (RequestID) RequestStart(ctx context.Context, _ core.Request) (context.Context, error) {
var b [16]byte
_, _ = rand.Read(b[:])
return context.WithValue(ctx, requestIDKey{}, hex.EncodeToString(b[:])), nil
}
func (RequestID) ResponseSend(ctx context.Context, res core.Response) error {
if id := RequestIDFrom(ctx); id != "" {
if res.Extensions == nil {
res.Extensions = map[string]any{}
}
res.Extensions["requestId"] = id
}
return nil
}
func (RequestID) Error(ctx context.Context, err error) {
if id := RequestIDFrom(ctx); id != "" {
// Replace with your real logger.
println("graphql error", id, err.Error())
}
}A few things worth calling out:
- The plugin embeds
plugins.Base, so future lifecycle hooks added to the interface won't break the build. RequestStartis the only hook that may return a derived context. Add request-scoped values here, never on the plugin struct.ResponseSendruns once per response. MutatingExtensionshere is the supported way to enrich the wire format from a plugin.
Register it
package main
import (
"log"
"log/slog"
"github.com/example/myproject/extensions"
"github.com/grx-gql/grx"
"github.com/grx-gql/grx/plugins/logger"
"example.com/hello-grx/graph"
)
func main() {
logPlug, err := logger.New(logger.Config{Logger: slog.Default()})
if err != nil {
log.Fatal(err)
}
srv, err := grx.NewServer(
grx.WithSchema(graph.Schema()),
grx.WithPlugins(logPlug, extensions.RequestID{}),
)
if err != nil {
log.Fatal(err)
}
_ = srv
}Plugins run in registration order. Put plugins that produce IDs before plugins that consume them (e.g. the logger).
Use it from a resolver
package graph
import (
"context"
"github.com/example/myproject/extensions"
)
func (Query) Whoami(ctx context.Context) (string, error) {
return extensions.RequestIDFrom(ctx), nil
}{ whoami }The response will look like:
{
"data": { "whoami": "1f1e2d3c4b5a69788796a5b4c3d2e1f0" },
"extensions": { "requestId": "1f1e2d3c4b5a69788796a5b4c3d2e1f0" }
}Where to go next
- Hook
FieldResolveStartto record per-field timing - but keep it cheap (it fires on every resolved field). - Hook
ParsingStartandValidationStartto enforce caps on document size or depth. - Use the same shape to bridge into OpenTelemetry: open a span in
RequestStart, attach it toctx, end it inResponseSend.
Last updated on
Deployment and scaling
Ship and scale a grx GraphQL server - replicas with HTTP, WebSocket and SSE, shared pub/sub, containers, reverse proxies, Kubernetes, managed platforms, graceful shutdown and health checks.
Custom transport
Implement core.Transport when the built-in HTTP, WebSocket, or SSE handlers are not enough.