🐈‍⬛

GraphQLにおけるQuery, Mutation単位のエラーレートの取得(99designs/gqlgen利用)

2024/06/30に公開

概要

弊社ではクラウドインフラとしてAWSを使い、BackendとFrontend間の通信にはもっぱらGraphQLを利用しています。
先ごろ、SLI/SLO策定のための取り組みとして、このGraphQLのQuery, Mutation単位でのエラーレートを取得したくなりました。
こういうときの一般的なソリューションとしては、Apiのエンドポイント単位でリクエストとレスポンスをカウントしていくというものがあるかと思います。ただ、GraphQLはエンドポイントが共通(/graphql)であり、エラーの場合も(一般的には)HTTPStatusコード200でレスポンスを作成するため、この方法を利用することはできない、という問題がありました。
そこで99designs/gqlgenのMiddlewareでCloudWatchMetricsへのカスタムメトリクスのPutを行うことで、Query, Mutation単位でのエラーレート計算を可能にすることができました。

実装

早速ですが、以下に実装例を記載します。

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"github.com/99designs/gqlgen/graphql"
	gqlHandler "github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/service/cloudwatch"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/rs/cors"
)

type httpServer struct {
	mux      *chi.Mux
	cwClient *cloudwatch.CloudWatch
}

const (
	healthCheckPath       = "/"
	customMetricNamespace = "graphql"
	dimensionName         = "Operations"
	unknownOperationName  = "unknown"
)

var ignorePaths = []string{healthCheckPath}

func newHTTPServer(cwClient *cloudwatch.CloudWatch) *httpServer {
	r := chi.NewRouter()

	r.Use(cors.New(cors.Options{
		AllowedHeaders: []string{"*"},
	}).Handler)
	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Recoverer)

	setUpGraphQL(r, cwClient)

	r.Get(healthCheckPath, func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("OK"))
	})

	return &httpServer{
		mux:      r,
		cwClient: cwClient,
	}
}

func setUpGraphQL(r *chi.Mux, cwClient *cloudwatch.CloudWatch) {
	srv := gqlHandler.NewDefaultServer(
		graphql.MustParseSchema(schema, &Resolver{}),
	)
	srv.AroundOperations(func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
		operationContext := graphql.GetOperationContext(ctx)
		if operationContext != nil {
			operationName := operationContext.OperationName
			if operationName == "" {
				operationName = unknownOperationName
			}
			putMetricData(cwClient, operationName+"-count")
		}
		res := next(ctx)
		return res
	})
	srv.AroundRootFields(func(ctx context.Context, next graphql.RootResolver) graphql.Marshaler {
		operationContext := graphql.GetOperationContext(ctx)
		func() {
			if len(graphql.GetErrors(ctx)) == 0 && operationContext != nil {
				operationName := operationContext.OperationName
				if operationName == "" {
					operationName = unknownOperationName
				}
				putMetricData(cwClient, operationName+"-success-count")
			}
		}()
		res := next(ctx)
		return res
	})
	r.Handle("/playground", playground.Handler("GraphQL playground", "/graphql"))
	r.Handle("/graphql", srv)
}

func (s *httpServer) Start() {
	svr := http.Server{
		Handler: s.mux,
		Addr:    ":8080",
	}
	if err := svr.ListenAndServe(); err != nil {
        fmt.Println(err)
	}
}

func putMetricData(cwClient *cloudwatch.CloudWatch, metricName string) {
	_, err := cwClient.PutMetricData(&cloudwatch.PutMetricDataInput{
		Namespace: aws.String(customMetricNamespace),
		MetricData: []*cloudwatch.MetricDatum{
			{
				MetricName: aws.String(metricName),
				Timestamp:  aws.Time(time.Now()),
				Value:      aws.Float64(1),
				Unit:       aws.String(cloudwatch.StandardUnitCount),
				Dimensions: []*cloudwatch.Dimension{
					{
						Name:  aws.String(dimensionName),
						Value: aws.String(metricName),
					},
				},
			},
		},
	})
	if err != nil {
		fmt.Println(err)
	}
}

解説

以下、一応の解説です。
はじめに、AroundOperationsでQuery, Mutationの呼び出しを記録します。
次にAroundRootFieldsでエラー非発生時のみ、当該オペレーションについての成功を記録します。あとはCloudWatchMetricsの方でこねこねすれば、エラーレートが取得できるという寸法です。
欠点としては、ここで記録されるのはGraphQLのリクエストに内包されているOperationNameに依存した値となるため、フロントエンド側での呼び出し単位に大きく左右されるという点があります。弊社の場合ではそれでも大きな困りごとはなさそうなのですが、利用状況によっては、これが難点になってしまうかもしれません。
また、PutMetricの回数がむやみやたらに多いよりは少ない方がいいかなという思いから、当初は成功時ではなくてエラー発生時に記録しようと思っていたのですが、どうも処理中でpanicが発生した場合AroundRootFieldsを経由しないようなので、現在のかたちに落ち着きました。ただこのあたりは、まだ十分に理解できているとは言い難く、何かより上手い方法とかあるかもしれないなと思っております。有識者の方からのご指摘をいただけますと泣いて喜びます。
なお、上記の実装にあたってはhsaki氏の「GraphQL特有のミドルウェア」記事を参考にさせていただきました。

Fivot Tech Blog

Discussion