/
usagerecorder.go
184 lines (160 loc) · 5.44 KB
/
usagerecorder.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
// Package usagerecorder provides a graphql extension to record graphql usage.
// This extension records graphql queries and variables from any service.
package usagerecorder
import (
"context"
"log/slog"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler/extension"
)
// GraphqlUsageRecorder records usages of graphql API calls. That is used to investigate API usages.
type GraphqlUsageRecorder struct {
emitter UsageEmitter
clock func() time.Time
extraValuesExtractor func(ctx context.Context, oc *graphql.OperationContext) map[string]interface{}
emitterErrorHandler func(err error)
logger *slog.Logger
emitVariables bool
}
type UsageRecorderOption func(*GraphqlUsageRecorder)
// WithEmitter sets the emitter to the recorder.
func WithEmitter(emitter UsageEmitter) UsageRecorderOption {
return func(r *GraphqlUsageRecorder) {
r.emitter = emitter
}
}
// WithClock sets the clock to the recorder.
func WithClock(clock func() time.Time) UsageRecorderOption {
return func(r *GraphqlUsageRecorder) {
r.clock = clock
}
}
// WithExternalValuesExtractor sets the external values extractor to the recorder.
func WithExternalValuesExtractor(extractor func(ctx context.Context, oc *graphql.OperationContext) map[string]interface{}) UsageRecorderOption {
return func(r *GraphqlUsageRecorder) {
r.extraValuesExtractor = extractor
}
}
// WithEmitterErrorHandler sets the emitter error handler to the recorder.
func WithEmitterErrorHandler(handler func(err error)) UsageRecorderOption {
return func(r *GraphqlUsageRecorder) {
r.emitterErrorHandler = handler
}
}
// WithLogger sets the logger to the recorder.
func WithLogger(logger *slog.Logger) UsageRecorderOption {
return func(r *GraphqlUsageRecorder) {
r.logger = logger
}
}
// WithEmitVariables sets the emitVariables flag to the recorder.
func WithEmitVariables(emitVariables bool) UsageRecorderOption {
return func(r *GraphqlUsageRecorder) {
r.emitVariables = emitVariables
}
}
// New creates a new GraphqlUsageRecorder.
func New(options ...UsageRecorderOption) *GraphqlUsageRecorder {
defaultLogger := slog.Default()
recorder := &GraphqlUsageRecorder{
emitter: &LogEmitter{Logger: defaultLogger},
clock: time.Now,
logger: defaultLogger,
extraValuesExtractor: func(ctx context.Context, oc *graphql.OperationContext) map[string]interface{} {
return make(map[string]interface{})
},
emitterErrorHandler: func(err error) {
defaultLogger.Error("failed to emit graphql usage", err)
},
emitVariables: false,
}
for _, opt := range options {
opt(recorder)
}
return recorder
}
// Guarantee GraphqlUsageRecorder implements interfaces.
// See https://go.dev/doc/faq#guarantee_satisfies_interface
var _ interface {
graphql.HandlerExtension
graphql.ResponseInterceptor
} = &GraphqlUsageRecorder{}
const extensionName = "usage-recorder"
func (g *GraphqlUsageRecorder) ExtensionName() string {
return extensionName
}
func (g *GraphqlUsageRecorder) Validate(schema graphql.ExecutableSchema) error {
return nil
}
func (g *GraphqlUsageRecorder) InterceptResponse(ctx context.Context, next graphql.ResponseHandler) *graphql.Response {
oc := graphql.GetOperationContext(ctx)
if oc.Operation == nil {
// This block is for invalid query
ua := g.GetUserAgent(oc)
g.logger.Warn("operation is nil.", "userAgent", ua)
return next(ctx)
}
if oc.Operation.Name == "IntrospectionQuery" {
return next(ctx)
}
defer func() {
usage := g.CollectGraphqlUsage(ctx, oc)
if err := g.emitter.Emit(usage); err != nil {
g.emitterErrorHandler(err)
}
}()
return next(ctx)
}
func (g *GraphqlUsageRecorder) CollectGraphqlUsage(ctx context.Context, oc *graphql.OperationContext) *GraphqlUsage {
complexityStats, ok := oc.Stats.GetExtension("ComplexityLimit").(*extension.ComplexityStats)
if !ok {
g.logger.Warn("failed to cast ComplexityStats")
complexityStats = &extension.ComplexityStats{
Complexity: 0,
ComplexityLimit: 0,
}
}
// extract dependent objects and fields
cfs := graphql.CollectFields(oc, oc.Operation.SelectionSet, nil)
var queryToReferencedTypes = make(map[string][]*ReferenceType, len(cfs))
for _, cf := range cfs {
refTypes := extractReferenceTypes(oc, cf, make([]*ReferenceType, 0))
queryToReferencedTypes[cf.Name] = refTypes
}
end := g.clock()
duration := end.Sub(oc.Stats.OperationStart)
usage := &GraphqlUsage{
OperationTime: oc.Stats.OperationStart,
QueryOperationName: oc.Operation.Name,
QueryComplexity: *complexityStats,
Query: oc.RawQuery,
ReferencedTypes: queryToReferencedTypes,
OperationMilliseconds: duration.Milliseconds(),
ExtraValues: g.extraValuesExtractor(ctx, oc),
}
if g.emitVariables {
usage.QueryVariables = oc.Variables
}
return usage
}
func (g *GraphqlUsageRecorder) GetUserAgent(oc *graphql.OperationContext) string {
ua, ok := oc.Headers["User-Agent"]
if !ok || len(ua) == 0 {
return "unknown"
}
return ua[0]
}
func extractReferenceTypes(oc *graphql.OperationContext, cf graphql.CollectedField, result []*ReferenceType) []*ReferenceType {
var fieldNames = make([]string, 0)
for _, f := range graphql.CollectFields(oc, cf.Selections, nil) {
if f.Selections != nil && len(f.Selections) > 0 {
result = extractReferenceTypes(oc, f, nil)
}
if f.Name != "" {
fieldNames = append(fieldNames, f.Name)
}
}
result = append(result, &ReferenceType{TypeName: cf.Name, Fields: fieldNames})
return result
}