wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

gqlgen + opentelemetry-go + Cloud Traceでパフォーマンス測定

backendのパフォーマンス測定を実装してみた

PR

github.com

OpenTelemetryとは

opentelemetry.io

  • OpenTelemetryは、オープンソースのオブザーバビリティフレームワーク
  • 運用しているアプリのパフォーマンスや健全性が正常な状態か判断するために使用する情報をテレメトリーデータと呼ばれている
  • テレメトリーデータは主に3つのカテゴリに分類される
    • ログ
    • メトリクス
    • トレース
  • OpenTelemetryは、それらのテレメトリデータを収集するためのベンダーに依存しないAPI、ソフトウェア開発キット(SDK)、その他のツールを提供している

使用技術

実装

opentelemetry-goCloud Traceのコードは以下を参考に実装

cloud.google.com

main.go

var tr trace.Tracer = sdktrace.NewTracerProvider().Tracer("memoir-backend")

projectID := os.Getenv("GCP_PROJECT_ID")
exporter, err := texporter.New(texporter.WithProjectID(projectID))
if err != nil {
  log.Fatalf("texporter.NewExporter: %v", err)
}

tp := sdktrace.NewTracerProvider(
  sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.01)),
  // sdktrace.WithSampler(sdktrace.AlwaysSample()), テスト時は毎回トレース情報が欲しいので、こちらを使用
  sdktrace.WithBatcher(exporter),
)

defer func() {
  if err := tp.ForceFlush(ctx); err != nil {
    log.Fatal(err)
  }
}()
otel.SetTracerProvider(tp)
tr = otel.GetTracerProvider().Tracer("memoir-backend")

(略)

// 上記のトレースのプロバイダをMiddlewareの引数に渡す
srv.Use(app_trace.NewGraphQLTracer(tr))

gqlgenにはMiddlewareを実装することでGraphQLの各QueryとField実行時のハンドリングが行えるので、そのハンドリングとopentelemetry-goを組み合わせると以下のようなコードになった。

usecase/app_trace/middleware.go

package app_trace

import (
    "context"
    "fmt"
    "strings"

    "github.com/99designs/gqlgen/graphql"

    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/trace"
)

type graphqlTracer struct {
    tracer trace.Tracer
}

var _ interface {
    graphql.HandlerExtension
    graphql.ResponseInterceptor
    graphql.FieldInterceptor
} = &graphqlTracer{}

func NewGraphQLTracer(tracer trace.Tracer) graphql.HandlerExtension {
    return &graphqlTracer{
        tracer: tracer,
    }
}

func (t graphqlTracer) ExtensionName() string {
    return "GraphQLTracer"
}

func (t graphqlTracer) Validate(_ graphql.ExecutableSchema) error {
    return nil
}

func (t graphqlTracer) InterceptResponse(
    ctx context.Context,
    next graphql.ResponseHandler,
) *graphql.Response {
    oc := graphql.GetOperationContext(ctx)
    if oc.Operation.Name == "IntrospectionQuery" {
        return next(ctx)
    }

    q := strings.Split(oc.RawQuery, " ")[0]
    ctx, span := t.tracer.Start(ctx, q+":"+oc.OperationName, trace.WithSpanKind(trace.SpanKindServer))
    defer span.End()
    if !span.IsRecording() {
        return next(ctx)
    }

    span.SetAttributes(
        attribute.Key("request.query").String(oc.RawQuery),
    )

    res := next(ctx)
    if res == nil {
        return res
    }

    if len(res.Errors) > 0 {
        span.SetStatus(codes.Error, res.Errors.Error())
        span.RecordError(fmt.Errorf(res.Errors.Error()))
        err := res.Errors[0]
        span.SetAttributes(attribute.Key("error.message").String(err.Message))

    }
    return res
}

func (t graphqlTracer) InterceptField(
    ctx context.Context,
    next graphql.Resolver,
) (interface{}, error) {
    oc := graphql.GetOperationContext(ctx)
    if oc.Operation.Name == "IntrospectionQuery" {
        return next(ctx)
    }

    fc := graphql.GetFieldContext(ctx)

    ctx, span := t.tracer.Start(ctx,
        fc.Field.ObjectDefinition.Name+"/"+fc.Field.Name,
        trace.WithSpanKind(trace.SpanKindServer),
    )
    defer span.End()
    if !span.IsRecording() {
        return next(ctx)
    }

    span.SetAttributes(
        attribute.Key("resolver.path").String(fc.Path().String()),
        attribute.Key("resolver.object").String(fc.Field.ObjectDefinition.Name),
        attribute.Key("resolver.field").String(fc.Field.Name),
        attribute.Key("resolver.alias").String(fc.Field.Alias),
    )

    argKV := []attribute.KeyValue{}
    for _, arg := range fc.Field.Arguments {
        if arg.Value != nil {
            argKV = append(argKV, attribute.Key(fmt.Sprintf("resolver.args.%s", arg.Name)).String(arg.Value.String()))
        }
    }

    if len(argKV) > 0 {
        span.SetAttributes(argKV...)
    }

    res, err := next(ctx)

    if err != nil {
        span.SetAttributes(attribute.Key("error.message").String(err.Error()))
    }

    errList := graphql.GetFieldErrors(ctx, fc)
    if len(errList) != 0 {
        span.SetStatus(codes.Error, errList.Error())
        span.RecordError(fmt.Errorf(errList.Error()))
        err := errList[0]
        span.SetAttributes(attribute.Key("error.message").String(err.Message))
    }

    return res, err
}

実装は、これで完了。 後は実際にGraphQLを実行すると以下のように、Cloud Traceで表示される。

複数Queryを実行した際にボトルネックになっている箇所などがわかりやすくなり、運用しやすくなった。