optics-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|