diff --git a/extensions/hive/reporter.go b/extensions/hive/reporter.go new file mode 100644 index 0000000..abf32ae --- /dev/null +++ b/extensions/hive/reporter.go @@ -0,0 +1,243 @@ +package extensions + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/99designs/gqlgen/graphql" + "github.com/google/uuid" + "github.com/vektah/gqlparser/v2/ast" + "go.uber.org/zap" +) + +type Report struct { + Size int `json:"size"` + Map map[string]OperationMapRecord `json:"map"` + Operations []Operation `json:"operations"` +} + +type OperationMapRecord struct { + Operation string `json:"operation"` + OperationName string `json:"operationName,omitempty"` + Fields []string `json:"fields"` +} + +type Operation struct { + OperationMapKey string `json:"operationMapKey"` + Timestamp int64 `json:"timestamp"` + Execution Execution `json:"execution"` + Metadata *Metadata `json:"metadata"` +} + +type Execution struct { + Ok bool `json:"ok"` + Duration int64 `json:"duration"` + ErrorsTotal int64 `json:"errorsTotal"` +} + +type Metadata struct { + Client Client `json:"client"` +} + +type Client struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` +} + +type Reporter struct { + Endpoint string + ApiToken string + BatchSize int + report *Report + logger *zap.Logger +} + +var _ interface { + graphql.HandlerExtension + graphql.ResponseInterceptor +} = Reporter{} + +func (Reporter) ExtensionName() string { + return "HiveReporter" +} + +func (Reporter) Validate(graphql.ExecutableSchema) error { + return nil +} + +func (r Reporter) InterceptResponse(ctx context.Context, next graphql.ResponseHandler) *graphql.Response { + if !graphql.HasOperationContext(ctx) { + return next(ctx) + } + + rc := graphql.GetOperationContext(ctx) + + start := rc.Stats.OperationStart + + resp := next(ctx) + + if rc.Operation == nil { + return resp + } + + fields := getPreloads(ctx, rc.Operation.SelectionSet) + + var mapKey string + + for key, omr := range r.report.Map { + if omr.Operation != rc.RawQuery { + continue + } + + if omr.OperationName != rc.OperationName { + continue + } + + if len(omr.Fields) != len(fields) { + continue + } + + found := false + + for _, f := range omr.Fields { + for _, _f := range fields { + if f == _f { + found = true + } + } + } + + if !found { + continue + } + + mapKey = key + + break + } + + if mapKey == "" { + id := uuid.New() + + opr := OperationMapRecord{ + Operation: rc.RawQuery, + OperationName: rc.OperationName, + Fields: fields, + } + + r.report.Map[id.String()] = opr + mapKey = id.String() + } + + end := graphql.Now() + duration := end.Sub(start) + + var ( + clientName = rc.Headers.Get("User-Agent") + clientVersion string + ) + + if parts := strings.Split(clientName, "/"); len(parts) > 1 { + clientName, clientVersion = parts[0], strings.Split(parts[1], " ")[0] + } + + op := Operation{ + OperationMapKey: mapKey, + Timestamp: start.UnixMilli(), + Execution: Execution{ + Ok: len(resp.Errors) == 0, + Duration: duration.Nanoseconds(), + ErrorsTotal: int64(len(resp.Errors)), + }, + Metadata: &Metadata{ + Client: Client{ + Name: clientName, + Version: clientVersion, + }, + }, + } + + r.report.Operations[r.report.Size] = op + r.report.Size++ + + if r.report.Size >= r.BatchSize { + r.logger.Info("Sending report to Hive", zap.String("Endpoint", r.Endpoint), zap.Int("Operations", len(r.report.Operations))) + jsonData, err := json.Marshal(r.report) + + if err != nil { + r.logger.Error("Error marshaling report", zap.Error(err)) + graphql.AddError(ctx, err) + } + + request, err := http.NewRequest(http.MethodPost, r.Endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + r.logger.Error("Error sending report to Hive", zap.Error(err)) + graphql.AddError(ctx, err) + } + + request.Header.Set("Content-Type", "application/json; charset=UTF-8") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.ApiToken)) + + client := &http.Client{} + + response, err := client.Do(request) + if err != nil { + r.logger.Error("Error sending report to Hive", zap.Error(err)) + graphql.AddError(ctx, err) + } + + r.logger.Info("Sent report to Hive", zap.Any("statusCode", response.StatusCode)) + + defer response.Body.Close() + + *r.report = Report{ + Map: map[string]OperationMapRecord{}, + Operations: make([]Operation, r.BatchSize), + } + } + + return resp +} + +func getPreloads(ctx context.Context, selectionSet ast.SelectionSet) []string { + return getNestedPreloads( + graphql.GetOperationContext(ctx), + graphql.CollectFields(graphql.GetOperationContext(ctx), selectionSet, nil), + "", + ) +} + +func getNestedPreloads(ctx *graphql.OperationContext, fields []graphql.CollectedField, prefix string) (preloads []string) { + for _, column := range fields { + prefixColumn := getPreloadString(prefix, column.Name) + preloads = append(preloads, prefixColumn) + preloads = append(preloads, getNestedPreloads(ctx, graphql.CollectFields(ctx, column.Selections, nil), prefixColumn)...) + } + + return +} + +func getPreloadString(prefix, name string) string { + if len(prefix) > 0 { + return prefix + "." + name + } + + return name +} + +func NewReporter(endpoint, apiToken string, batchSize int, logger *zap.Logger) *Reporter { + return &Reporter{ + Endpoint: endpoint, + ApiToken: apiToken, + BatchSize: batchSize, + report: &Report{ + Map: map[string]OperationMapRecord{}, + Operations: make([]Operation, batchSize), + }, + logger: logger, + } +} diff --git a/go.mod b/go.mod index fb804e1..82af50c 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.21.5 require ( github.com/99designs/gqlgen v0.17.49 github.com/getsentry/sentry-go v0.28.1 + github.com/google/uuid v1.6.0 + github.com/vektah/gqlparser/v2 v2.5.16 + go.uber.org/zap v1.27.0 ) require ( - github.com/google/uuid v1.6.0 // indirect github.com/sosodev/duration v1.3.1 // indirect - github.com/vektah/gqlparser/v2 v2.5.16 // indirect + go.uber.org/multierr v1.10.0 // indirect golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.16.0 // indirect ) diff --git a/go.sum b/go.sum index 3766960..a80d3a1 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,12 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=