Skip to content

Commit 0342949

Browse files
authored
Merge pull request rmosolgo#354 from rmosolgo/instrumentation
Instrumentation
2 parents de1ed2a + 522e430 commit 0342949

File tree

6 files changed

+227
-21
lines changed

6 files changed

+227
-21
lines changed

guides/schema/instrumentation.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
title: Schema — Instrumentation
3+
---
4+
5+
Instrumentation provides hooks for inserting custom code around field resolution and query execution.
6+
7+
## Field Instrumentation
8+
9+
Field instrumentation can be attached during schema definition:
10+
11+
```ruby
12+
MySchema = GraphQL::Schema.define do
13+
instrument(:field, FieldTimerInstrumentation.new)
14+
end
15+
```
16+
17+
The instrumenter is an object which responds to `#instrument(type, field)`. `#instrument` should return a `GraphQL::Field` instance which will be used during query execution. `#instrument` is called with each type-field pair for _all_ Object types and Interface types in your schema.
18+
19+
Here's an example field instrumenter:
20+
21+
```ruby
22+
class FieldTimerInstrumentation
23+
# If a field was flagged to be timed,
24+
# wrap its resolve proc with a timer.
25+
def instrument(type, field)
26+
if field.metadata[:timed]
27+
old_resolve_proc = field.resolve_proc
28+
new_resolve_proc = ->(obj, args, ctx) {
29+
Rails.logger.info("#{type.name}.#{field.name} START: #{Time.now.to_i}")
30+
old_resolve_proc.call(obj, args, ctx)
31+
Rails.logger.info("#{type.name}.#{field.name} END: #{Time.now.to_i}")
32+
}
33+
34+
# Return a copy of `field`, with a new resolve proc
35+
field.redefine do
36+
resolve(new_resolve_proc)
37+
end
38+
end
39+
end
40+
end
41+
```
42+
43+
It can be attached as shown above. You can use `redefine { ... }` to make a shallow copy of the {{ "GraphQL::Field" | api_doc }} and extend its definition.
44+
45+
## Query Instrumentation
46+
47+
48+
Query instrumentation can be attached during schema definition:
49+
50+
```ruby
51+
MySchema = GraphQL::Schema.define do
52+
instrument(:query, QueryTimerInstrumentation)
53+
end
54+
```
55+
56+
The instrumenter must implement `#before_query(query)` and `#after_query(query)`. The return values of these methods are not used. They receive the {{ "GraphQL::Query" | api_doc }} instance.
57+
58+
```ruby
59+
module MyQueryInstrumentation
60+
module_function
61+
62+
# Log the time of the query
63+
def before_query(query)
64+
Rails.logger.info("Query begin: #{Time.now.to_i}")
65+
end
66+
67+
def after_query(query)
68+
Rails.logger.info("Query end: #{Time.now.to_i}")
69+
end
70+
end
71+
```

lib/graphql/query.rb

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,16 +91,9 @@ def initialize(schema, query_string = nil, document: nil, context: nil, variable
9191
# Get the result for this query, executing it once
9292
def result
9393
@result ||= begin
94-
if !valid?
95-
all_errors = validation_errors + analysis_errors
96-
if all_errors.any?
97-
{ "errors" => all_errors.map(&:to_h) }
98-
else
99-
nil
100-
end
101-
else
102-
Executor.new(self).result
103-
end
94+
instrumenters = @schema.instrumenters[:query]
95+
execution_call = ExecutionCall.new(self, instrumenters)
96+
execution_call.call
10497
end
10598
end
10699

@@ -209,5 +202,37 @@ def find_operation(operations, operation_name)
209202
operations[operation_name]
210203
end
211204
end
205+
206+
class ExecutionCall
207+
def initialize(query, instrumenters)
208+
@query = query
209+
@instrumenters = instrumenters
210+
end
211+
212+
# Check if the query is valid, and if it is,
213+
# execute it, calling instrumenters along the way
214+
# @return [Hash] The GraphQL response
215+
def call
216+
@instrumenters.each { |i| i.before_query(@query) }
217+
result = get_result
218+
@instrumenters.each { |i| i.after_query(@query) }
219+
result
220+
end
221+
222+
private
223+
224+
def get_result
225+
if !@query.valid?
226+
all_errors = @query.validation_errors + @query.analysis_errors
227+
if all_errors.any?
228+
{ "errors" => all_errors.map(&:to_h) }
229+
else
230+
nil
231+
end
232+
else
233+
Executor.new(@query).result
234+
end
235+
end
236+
end
212237
end
213238
end

lib/graphql/query/variables.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ module GraphQL
22
class Query
33
# Read-only access to query variables, applying default values if needed.
44
class Variables
5+
extend Forwardable
6+
57
# @return [Array<GraphQL::Query::VariableValidationError>] Any errors encountered when parsing the provided variables and literal values
68
attr_reader :errors
79

@@ -20,6 +22,8 @@ def [](key)
2022
@storage.fetch(key)
2123
end
2224

25+
def_delegators :@storage, :length
26+
2327
private
2428

2529
# Find the right value for this variable:

lib/graphql/schema.rb

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require "graphql/schema/catchall_middleware"
22
require "graphql/schema/invalid_type_error"
3+
require "graphql/schema/instrumented_field_map"
34
require "graphql/schema/middleware_chain"
45
require "graphql/schema/possible_types"
56
require "graphql/schema/rescue_middleware"
@@ -55,6 +56,7 @@ class Schema
5556
:orphan_types, :resolve_type,
5657
:object_from_id, :id_from_object,
5758
directives: ->(schema, directives) { schema.directives = directives.reduce({}) { |m, d| m[d.name] = d; m }},
59+
instrument: -> (schema, type, instrumenter) { schema.instrumenters[type] << instrumenter },
5860
query_analyzer: ->(schema, analyzer) { schema.query_analyzers << analyzer },
5961
middleware: ->(schema, middleware) { schema.middleware << middleware },
6062
rescue_from: ->(schema, err_class, &block) { schema.rescue_from(err_class, &block)}
@@ -64,7 +66,7 @@ class Schema
6466
:query_execution_strategy, :mutation_execution_strategy, :subscription_execution_strategy,
6567
:max_depth, :max_complexity,
6668
:orphan_types, :directives,
67-
:query_analyzers, :middleware
69+
:query_analyzers, :middleware, :instrumenters
6870

6971
BUILT_IN_TYPES = Hash[[INT_TYPE, STRING_TYPE, FLOAT_TYPE, BOOLEAN_TYPE, ID_TYPE].map{ |type| [type.name, type] }]
7072
DIRECTIVES = [GraphQL::Directive::IncludeDirective, GraphQL::Directive::SkipDirective, GraphQL::Directive::DeprecatedDirective]
@@ -89,6 +91,7 @@ def initialize
8991
@resolve_type_proc = nil
9092
@object_from_id_proc = nil
9193
@id_from_object_proc = nil
94+
@instrumenters = Hash.new { |h, k| h[k] = [] }
9295
# Default to the built-in execution strategy:
9396
@query_execution_strategy = GraphQL::Query::SerialExecution
9497
@mutation_execution_strategy = GraphQL::Query::SerialExecution
@@ -107,7 +110,23 @@ def remove_handler(*args, &block)
107110

108111
def define(**kwargs, &block)
109112
super
110-
types
113+
all_types = orphan_types + [query, mutation, subscription, GraphQL::Introspection::SchemaType]
114+
@types = GraphQL::Schema::ReduceTypes.reduce(all_types.compact)
115+
116+
@instrumented_field_map = InstrumentedFieldMap.new(self)
117+
field_instrumenters = @instrumenters[:field]
118+
types.each do |type_name, type|
119+
if type.kind.fields?
120+
type.all_fields.each do |field_defn|
121+
122+
instrumented_field_defn = field_instrumenters.reduce(field_defn) do |defn, inst|
123+
inst.instrument(type, defn)
124+
end
125+
126+
@instrumented_field_map.set(type.name, field_defn.name, instrumented_field_defn)
127+
end
128+
end
129+
end
111130
# Assert that all necessary configs are present:
112131
validation_error = Validation.validate(self)
113132
validation_error && raise(NotImplementedError, validation_error)
@@ -117,13 +136,7 @@ def define(**kwargs, &block)
117136

118137
# @see [GraphQL::Schema::Warden] Restricted access to members of a schema
119138
# @return [GraphQL::Schema::TypeMap] `{ name => type }` pairs of types in this schema
120-
def types
121-
@types ||= begin
122-
ensure_defined
123-
all_types = orphan_types + [query, mutation, subscription, GraphQL::Introspection::SchemaType]
124-
GraphQL::Schema::ReduceTypes.reduce(all_types.compact)
125-
end
126-
end
139+
attr_reader :types
127140

128141
# Execute a query on itself.
129142
# See {Query#initialize} for arguments.
@@ -139,8 +152,7 @@ def execute(*args)
139152
# @return [GraphQL::Field, nil] The field named `field_name` on `parent_type`
140153
def get_field(parent_type, field_name)
141154
ensure_defined
142-
143-
defined_field = parent_type.get_field(field_name)
155+
defined_field = @instrumented_field_map.get(parent_type.name, field_name)
144156
if defined_field
145157
defined_field
146158
elsif field_name == "__typename"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
module GraphQL
2+
class Schema
3+
# A two-level map with fields as the last values.
4+
# The first level is type names, which point to a second map.
5+
# The second level is field names, which point to fields.
6+
#
7+
# The catch is, the fields in this map _may_ have been modified by being instrumented.
8+
class InstrumentedFieldMap
9+
def initialize(schema)
10+
@storage = Hash.new { |h, k| h[k] = {} }
11+
end
12+
13+
def set(type_name, field_name, field)
14+
@storage[type_name][field_name] = field
15+
end
16+
17+
def get(type_name, field_name)
18+
type = @storage[type_name]
19+
type && type[field_name]
20+
end
21+
end
22+
end
23+
end

spec/graphql/schema_spec.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,75 @@
182182
assert_equal GraphQL::Schema::Printer.print_schema(schema), GraphQL::Schema::Printer.print_schema(built_schema)
183183
end
184184
end
185+
186+
describe "#instrument" do
187+
class MultiplyInstrumenter
188+
def initialize(multiplier)
189+
@multiplier = multiplier
190+
end
191+
192+
def instrument(type_defn, field_defn)
193+
if type_defn.name == "Query" && field_defn.name == "int"
194+
prev_proc = field_defn.resolve_proc
195+
new_resolve_proc = ->(obj, args, ctx) {
196+
inner_value = prev_proc.call(obj, args, ctx)
197+
inner_value * @multiplier
198+
}
199+
200+
field_defn.redefine do
201+
resolve(new_resolve_proc)
202+
end
203+
else
204+
field_defn
205+
end
206+
end
207+
end
208+
209+
class VariableCountInstrumenter
210+
attr_reader :counts
211+
def initialize
212+
@counts = []
213+
end
214+
215+
def before_query(query)
216+
@counts << query.variables.length
217+
end
218+
219+
def after_query(query)
220+
end
221+
end
222+
223+
let(:variable_counter) {
224+
VariableCountInstrumenter.new
225+
}
226+
let(:query_type) {
227+
GraphQL::ObjectType.define do
228+
name "Query"
229+
field :int, types.Int do
230+
argument :value, types.Int
231+
resolve -> (obj, args, ctx) { args[:value] }
232+
end
233+
end
234+
}
235+
236+
let(:schema) {
237+
spec = self
238+
GraphQL::Schema.define do
239+
query(spec.query_type)
240+
instrument(:field, MultiplyInstrumenter.new(3))
241+
instrument(:query, spec.variable_counter)
242+
end
243+
}
244+
245+
it "can modify field definitions" do
246+
res = schema.execute(" { int(value: 2) } ")
247+
assert_equal 6, res["data"]["int"]
248+
end
249+
250+
it "can wrap query execution" do
251+
schema.execute("query getInt($val: Int = 5){ int(value: $val) } ")
252+
schema.execute("query getInt($val: Int = 5, $val2: Int = 3){ int(value: $val) int2: int(value: $val2) } ")
253+
assert_equal [1, 2], variable_counter.counts
254+
end
255+
end
185256
end

0 commit comments

Comments
 (0)