optics-agent 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/lib/apollo/optics/proto/reports_pb.rb +145 -0
- data/lib/optics-agent.rb +4 -0
- data/lib/optics-agent/agent.rb +56 -0
- data/lib/optics-agent/graphql-middleware.rb +18 -0
- data/lib/optics-agent/instrumentation/introspection-query.graphql +91 -0
- data/lib/optics-agent/instrumentation/query-schema.rb +9 -0
- data/lib/optics-agent/normalization/latency.rb +14 -0
- data/lib/optics-agent/normalization/query.rb +156 -0
- data/lib/optics-agent/rack-middleware.rb +43 -0
- data/lib/optics-agent/reporting/helpers.rb +32 -0
- data/lib/optics-agent/reporting/query-trace.rb +54 -0
- data/lib/optics-agent/reporting/query.rb +65 -0
- data/lib/optics-agent/reporting/report.rb +102 -0
- data/lib/optics-agent/reporting/report_job.rb +24 -0
- data/lib/optics-agent/reporting/schema.rb +56 -0
- data/lib/optics-agent/reporting/schema_job.rb +14 -0
- data/lib/optics-agent/reporting/send-message.rb +23 -0
- data/spec/graphql-middleware_spec.rb +46 -0
- data/spec/latency_spec.rb +34 -0
- data/spec/query-normalization_spec.rb +105 -0
- data/spec/query_trace_spec.rb +43 -0
- data/spec/report_spec.rb +194 -0
- data/spec/schema-introspection_spec.rb +27 -0
- data/spec/schema_spec.rb +33 -0
- data/spec/spec_helper.rb +103 -0
- metadata +148 -0
@@ -0,0 +1,14 @@
|
|
1
|
+
module OpticsAgent
|
2
|
+
module Normalization
|
3
|
+
def empty_latency_count
|
4
|
+
Array.new(256) { 0 }
|
5
|
+
end
|
6
|
+
|
7
|
+
# see https://github.com/apollostack/optics-agent/blob/master/docs/histograms.md
|
8
|
+
def latency_bucket(micros)
|
9
|
+
bucket = Math.log(micros) / Math.log(1.1)
|
10
|
+
|
11
|
+
[255, [0, bucket].max].min.ceil.to_i
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
require 'graphql'
|
2
|
+
|
3
|
+
module OpticsAgent
|
4
|
+
module Normalization
|
5
|
+
module Query
|
6
|
+
include GraphQL::Language
|
7
|
+
|
8
|
+
# query is a query string
|
9
|
+
def normalize(query_string)
|
10
|
+
document = GraphQL.parse(query_string)
|
11
|
+
|
12
|
+
# to store results
|
13
|
+
output = ''
|
14
|
+
used_fragment_names = []
|
15
|
+
current = {}
|
16
|
+
visitor = Visitor.new(document)
|
17
|
+
|
18
|
+
stack = []
|
19
|
+
visitor.enter << -> (_, _) do
|
20
|
+
stack.unshift(
|
21
|
+
arguments: [],
|
22
|
+
directives: [],
|
23
|
+
selections: []
|
24
|
+
)
|
25
|
+
end
|
26
|
+
visitor.leave << -> (_, _) { current = stack.shift }
|
27
|
+
|
28
|
+
visitor[Nodes::Argument].leave << -> (node, parent) do
|
29
|
+
stack[0][:arguments] << "#{node.name}:#{genericize_type(node.value)}"
|
30
|
+
end
|
31
|
+
|
32
|
+
visitor[Nodes::Directive].leave << -> (node, parent) do
|
33
|
+
id = "@#{node.name}"
|
34
|
+
arguments = current[:arguments]
|
35
|
+
unless arguments.empty?
|
36
|
+
id << "(#{arguments.sort.join(', ')})"
|
37
|
+
end
|
38
|
+
stack[0][:directives] << id
|
39
|
+
end
|
40
|
+
|
41
|
+
visitor[Nodes::Field].leave << -> (node, parent) do
|
42
|
+
id = node.name
|
43
|
+
arguments = current[:arguments]
|
44
|
+
unless arguments.empty?
|
45
|
+
id << "(#{arguments.sort.join(', ')})"
|
46
|
+
end
|
47
|
+
directives = current[:directives]
|
48
|
+
unless directives.empty?
|
49
|
+
id << " #{directives.sort.join(' ')}"
|
50
|
+
end
|
51
|
+
selections = current[:selections]
|
52
|
+
unless selections.empty?
|
53
|
+
id << ' ' + block(selections)
|
54
|
+
end
|
55
|
+
|
56
|
+
stack[0][:selections] << id
|
57
|
+
end
|
58
|
+
|
59
|
+
visitor[Nodes::InlineFragment].leave << -> (node, parent) do
|
60
|
+
selections = current[:selections]
|
61
|
+
stack[0][:selections] << "... on #{node.type} #{block(selections)}"
|
62
|
+
end
|
63
|
+
|
64
|
+
visitor[Nodes::FragmentSpread].leave << -> (node, parent) do
|
65
|
+
used_fragment_names << node.name
|
66
|
+
stack[0][:selections] << "...#{node.name}"
|
67
|
+
end
|
68
|
+
|
69
|
+
visitor[Nodes::OperationDefinition].leave << -> (node, parent) do
|
70
|
+
# no need to walk this, I don't think anything else can have vars
|
71
|
+
vars = nil
|
72
|
+
unless node.variables.empty?
|
73
|
+
variable_strs = node.variables.sort_by(&:name).map do |variable|
|
74
|
+
"$#{variable.name}:#{format_argument_type(variable.type)}"
|
75
|
+
end
|
76
|
+
vars = "(#{variable_strs.join(',')})"
|
77
|
+
end
|
78
|
+
|
79
|
+
query_content = block(current[:selections])
|
80
|
+
if (node.name || vars || node.operation_type != 'query')
|
81
|
+
parts = [node.operation_type]
|
82
|
+
parts << "#{node.name}#{vars}" if (node.name || vars)
|
83
|
+
parts << query_content
|
84
|
+
output << parts.join(' ')
|
85
|
+
else
|
86
|
+
output << query_content
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
visitor[Nodes::FragmentDefinition].leave << -> (node, parent) do
|
91
|
+
selections = current[:selections]
|
92
|
+
if (used_fragment_names.include?(node.name))
|
93
|
+
output << " fragment #{node.name} on #{node.type} " \
|
94
|
+
+ block(selections)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
visitor.visit
|
99
|
+
output
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
# See https://github.com/apollostack/optics-agent/blob/master/docs/signatures.md
|
105
|
+
def sort_value(a)
|
106
|
+
type_value = if a[0..3] == '... '
|
107
|
+
2
|
108
|
+
elsif a[0..2] == '...'
|
109
|
+
1
|
110
|
+
else
|
111
|
+
0
|
112
|
+
end
|
113
|
+
|
114
|
+
[type_value, a]
|
115
|
+
end
|
116
|
+
|
117
|
+
def block(array)
|
118
|
+
if array.empty?
|
119
|
+
'{}'
|
120
|
+
else
|
121
|
+
"{#{array.sort_by{ |x| sort_value(x) }.join(' ')}}"
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def format_argument_type(type)
|
126
|
+
case type
|
127
|
+
when Nodes::ListType
|
128
|
+
"[#{format_argument_type(type.of_type)}]"
|
129
|
+
when Nodes::NonNullType
|
130
|
+
"#{format_argument_type(type.of_type)}!"
|
131
|
+
else
|
132
|
+
type.name
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def genericize_type(value)
|
137
|
+
case value
|
138
|
+
when Nodes::VariableIdentifier
|
139
|
+
"$#{value.name}"
|
140
|
+
when String
|
141
|
+
"\"\""
|
142
|
+
when Numeric
|
143
|
+
"0"
|
144
|
+
when TrueClass, FalseClass
|
145
|
+
value.to_s
|
146
|
+
when Array
|
147
|
+
"[]"
|
148
|
+
when Nodes::Enum
|
149
|
+
value.name
|
150
|
+
when Nodes::InputObject
|
151
|
+
"{}"
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'optics-agent/agent'
|
2
|
+
require 'optics-agent/reporting/query'
|
3
|
+
|
4
|
+
module OpticsAgent
|
5
|
+
class RackMiddleware
|
6
|
+
def initialize(app, options={})
|
7
|
+
@app = app
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(env)
|
11
|
+
begin
|
12
|
+
start_time = Time.now
|
13
|
+
|
14
|
+
# XXX: figure out a way to pass this in here
|
15
|
+
agent = OpticsAgent::Agent.instance
|
16
|
+
query = OpticsAgent::Reporting::Query.new
|
17
|
+
|
18
|
+
# Attach so resolver middleware can access
|
19
|
+
env[:optics_agent] = {
|
20
|
+
agent: agent,
|
21
|
+
query: query
|
22
|
+
}
|
23
|
+
env[:optics_agent].define_singleton_method(:with_document) do |document|
|
24
|
+
self[:query].document = document
|
25
|
+
self
|
26
|
+
end
|
27
|
+
|
28
|
+
result = @app.call(env)
|
29
|
+
|
30
|
+
# XXX: this approach means if the user forgets to call with_document
|
31
|
+
# we just never log queries. Can we detect if the request is a graphql one?
|
32
|
+
if (query.document)
|
33
|
+
agent.add_query(query, env, start_time, Time.now)
|
34
|
+
end
|
35
|
+
|
36
|
+
result
|
37
|
+
rescue Exception => e
|
38
|
+
puts "Rack Middleware Error: #{e}"
|
39
|
+
puts e.backtrace
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'apollo/optics/proto/reports_pb'
|
2
|
+
|
3
|
+
module OpticsAgent::Reporting
|
4
|
+
def generate_report_header
|
5
|
+
# XXX: fill out
|
6
|
+
Apollo::Optics::Proto::ReportHeader.new({
|
7
|
+
agent_version: '0'
|
8
|
+
})
|
9
|
+
end
|
10
|
+
|
11
|
+
def generate_timestamp(time)
|
12
|
+
Apollo::Optics::Proto::Timestamp.new({
|
13
|
+
seconds: time.to_i,
|
14
|
+
nanos: time.to_i % 1 * 1e9
|
15
|
+
});
|
16
|
+
end
|
17
|
+
|
18
|
+
# XXX: implement
|
19
|
+
def client_info(rack_env)
|
20
|
+
{
|
21
|
+
client_name: 'none',
|
22
|
+
client_version: 'none',
|
23
|
+
client_address: '::1'
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
def add_latency(counts, start_time, end_time)
|
28
|
+
micros = (end_time - start_time) * 1e6
|
29
|
+
bucket = latency_bucket(micros)
|
30
|
+
counts[bucket] += 1
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'apollo/optics/proto/reports_pb'
|
2
|
+
require 'optics-agent/reporting/helpers'
|
3
|
+
require 'optics-agent/reporting/send-message'
|
4
|
+
|
5
|
+
module OpticsAgent::Reporting
|
6
|
+
# A trace is just a different view of a single query report, with full
|
7
|
+
# information about start and end times
|
8
|
+
class QueryTrace
|
9
|
+
include OpticsAgent::Reporting
|
10
|
+
include Apollo::Optics::Proto
|
11
|
+
|
12
|
+
attr_accessor :report
|
13
|
+
|
14
|
+
def initialize(query, rack_env, start_time, end_time)
|
15
|
+
trace = Trace.new({
|
16
|
+
start_time: generate_timestamp(start_time),
|
17
|
+
signature: query.signature
|
18
|
+
})
|
19
|
+
|
20
|
+
# XXX: report trace details (not totally clear yet from the JS agent what should be here)
|
21
|
+
trace.details = Trace::Details.new({})
|
22
|
+
|
23
|
+
info = client_info(rack_env)
|
24
|
+
trace.client_name = info[:client_name]
|
25
|
+
trace.client_version = info[:client_version]
|
26
|
+
trace.client_address = info[:client_address]
|
27
|
+
trace.http = Trace::HTTPInfo.new({
|
28
|
+
host: "localhost:8080",
|
29
|
+
path: "/graphql"
|
30
|
+
})
|
31
|
+
|
32
|
+
nodes = []
|
33
|
+
query.each_report do |type_name, field_name, field_start_time, field_end_time|
|
34
|
+
nodes << Trace::Node.new({
|
35
|
+
field_name: "#{type_name}.#{field_name}",
|
36
|
+
start_time: ((field_start_time - start_time) * 1e9).to_i,
|
37
|
+
end_time: ((field_end_time - start_time) * 1e9).to_i
|
38
|
+
})
|
39
|
+
end
|
40
|
+
trace.execute = Trace::Node.new({
|
41
|
+
child: nodes
|
42
|
+
})
|
43
|
+
|
44
|
+
@report = TracesReport.new({
|
45
|
+
header: generate_report_header,
|
46
|
+
trace: [trace]
|
47
|
+
})
|
48
|
+
end
|
49
|
+
|
50
|
+
def send
|
51
|
+
send_message('/api/ss/traces', @report)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'apollo/optics/proto/reports_pb'
|
2
|
+
require 'optics-agent/reporting/helpers'
|
3
|
+
require 'optics-agent/normalization/latency'
|
4
|
+
|
5
|
+
module OpticsAgent::Reporting
|
6
|
+
# This is a convenience class that enables us to fairly blindly
|
7
|
+
# pass in data as we resolve a query
|
8
|
+
class Query
|
9
|
+
include Apollo::Optics::Proto
|
10
|
+
include OpticsAgent::Reporting
|
11
|
+
include OpticsAgent::Normalization
|
12
|
+
|
13
|
+
attr_accessor :document
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@reports = []
|
17
|
+
|
18
|
+
@document = nil
|
19
|
+
end
|
20
|
+
|
21
|
+
def signature
|
22
|
+
# Note this isn't actually possible but would be a sensible spot to throw
|
23
|
+
# if the user forgets to call `.with_document`
|
24
|
+
unless @document
|
25
|
+
throw "You must call .with_document on the optics context"
|
26
|
+
end
|
27
|
+
|
28
|
+
# TODO: query normalization here
|
29
|
+
return document["query"].to_s
|
30
|
+
end
|
31
|
+
|
32
|
+
# we do nothing when reporting to minimize impact
|
33
|
+
def report_field(type_name, field_name, start_time, end_time)
|
34
|
+
@reports << [type_name, field_name, start_time, end_time]
|
35
|
+
end
|
36
|
+
|
37
|
+
def each_report
|
38
|
+
@reports.each do |report|
|
39
|
+
yield *report
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# add our results to an existing StatsPerSignature
|
44
|
+
def add_to_stats(stats_per_signature)
|
45
|
+
each_report do |type_name, field_name, start_time, end_time|
|
46
|
+
type_stat = stats_per_signature.per_type.find { |ts| ts.name == type_name }
|
47
|
+
unless type_stat
|
48
|
+
type_stat = TypeStat.new({ name: type_name })
|
49
|
+
stats_per_signature.per_type << type_stat
|
50
|
+
end
|
51
|
+
|
52
|
+
field_stat = type_stat.field.find { |fs| fs.name == field_name }
|
53
|
+
unless field_stat
|
54
|
+
field_stat = FieldStat.new({
|
55
|
+
name: field_name,
|
56
|
+
latency_count: empty_latency_count
|
57
|
+
})
|
58
|
+
type_stat.field << field_stat
|
59
|
+
end
|
60
|
+
|
61
|
+
add_latency(field_stat.latency_count, start_time, end_time)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'apollo/optics/proto/reports_pb'
|
2
|
+
require 'optics-agent/reporting/send-message'
|
3
|
+
require 'optics-agent/reporting/helpers'
|
4
|
+
require 'optics-agent/normalization/latency'
|
5
|
+
|
6
|
+
module OpticsAgent::Reporting
|
7
|
+
# This class represents a complete report that we send to the optics server
|
8
|
+
# It pretty closely wraps the StatsReport protobuf message with a few
|
9
|
+
# convenience methods
|
10
|
+
class Report
|
11
|
+
include Apollo::Optics::Proto
|
12
|
+
include OpticsAgent::Reporting
|
13
|
+
include OpticsAgent::Normalization
|
14
|
+
|
15
|
+
attr_accessor :report
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
# internal report that we encapsulate
|
19
|
+
@report = StatsReport.new({
|
20
|
+
header: ReportHeader.new({
|
21
|
+
agent_version: '1'
|
22
|
+
}),
|
23
|
+
start_time: Timestamp.new({
|
24
|
+
# XXX pass this in?
|
25
|
+
seconds: Time.now.to_i,
|
26
|
+
nanos: 0
|
27
|
+
})
|
28
|
+
})
|
29
|
+
end
|
30
|
+
|
31
|
+
def finish!
|
32
|
+
@report.end_time ||= Timestamp.new({
|
33
|
+
# XXX pass this in?
|
34
|
+
seconds: Time.now.to_i,
|
35
|
+
nanos: 0
|
36
|
+
})
|
37
|
+
end
|
38
|
+
|
39
|
+
def send
|
40
|
+
self.finish!
|
41
|
+
send_message('/api/ss/stats', @report)
|
42
|
+
end
|
43
|
+
|
44
|
+
# XXX: record timing / client
|
45
|
+
def add_query(query, rack_env, start_time, end_time)
|
46
|
+
@report.per_signature[query.signature] ||= StatsPerSignature.new
|
47
|
+
signature_stats = @report.per_signature[query.signature]
|
48
|
+
|
49
|
+
add_client_stats(signature_stats, rack_env, start_time, end_time)
|
50
|
+
query.add_to_stats(signature_stats)
|
51
|
+
end
|
52
|
+
|
53
|
+
def add_client_stats(signature_stats, rack_env, start_time, end_time)
|
54
|
+
info = client_info(rack_env)
|
55
|
+
signature_stats.per_client_name[info[:client_name]] ||= StatsPerClientName.new({
|
56
|
+
latency_count: empty_latency_count,
|
57
|
+
error_count: empty_latency_count
|
58
|
+
})
|
59
|
+
client_stats = signature_stats.per_client_name[info[:client_name]]
|
60
|
+
|
61
|
+
# XXX: handle errors
|
62
|
+
add_latency(client_stats.latency_count, start_time, end_time)
|
63
|
+
|
64
|
+
client_stats.count_per_version[info[:client_version]] ||= 0
|
65
|
+
client_stats.count_per_version[info[:client_version]] += 1
|
66
|
+
end
|
67
|
+
|
68
|
+
# take a graphql schema and add returnTypes to all the fields on our report
|
69
|
+
def decorate_from_schema(schema)
|
70
|
+
each_field do |type_stat, field_stat|
|
71
|
+
# short circuit for special fields
|
72
|
+
field_stat.returnType = type_stat.name if field_stat.name == '__typename'
|
73
|
+
|
74
|
+
if type_stat.name == 'Query'
|
75
|
+
field_stat.returnType = '__Type' if field_stat.name == '__type'
|
76
|
+
field_stat.returnType = '__Schema' if field_stat.name == '__schema'
|
77
|
+
end
|
78
|
+
|
79
|
+
if field_stat.returnType.empty?
|
80
|
+
type = schema.types[type_stat.name]
|
81
|
+
throw "Type #{type_stat.name} not found!" unless type
|
82
|
+
|
83
|
+
field = type.fields[field_stat.name]
|
84
|
+
throw "Field #{type_stat.name}.#{field_stat.name} not found!" unless field
|
85
|
+
|
86
|
+
field_stat.returnType = field.type.to_s
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# do something once per field we've collected
|
92
|
+
def each_field
|
93
|
+
@report.per_signature.values.each do |sps|
|
94
|
+
sps.per_type.each do |type|
|
95
|
+
type.field.each do |field|
|
96
|
+
yield type, field
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|