graphql-metrics 2.0.1 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -5,6 +5,12 @@ Rake::TestTask.new(:test) do |t|
5
5
  t.libs << "test"
6
6
  t.libs << "lib"
7
7
  t.test_files = FileList["test/**/*_test.rb"]
8
+
9
+ # TODO: We should remove this line. See `puts` line below.
10
+ t.warning = false
11
+
12
+ puts "Reminder: Remove `t.warning = false` in Rakefile once graphql-ruby fixes all instances of"\
13
+ "`warning: instance variable @<ivar> not initialized` and `mismatched indentations`"
8
14
  end
9
15
 
10
16
  task :default => :test
@@ -1,14 +1,12 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require "bundler/setup"
4
- require "graphql_metrics"
4
+ require 'graphql'
5
+ require "graphql/metrics"
5
6
 
6
7
  # You can add fixtures and/or initialization code here to make experimenting
7
8
  # with your gem easier. You can also use a different console, if you like.
8
9
 
9
10
  # (If you use this, don't forget to add pry to your Gemfile!)
10
- # require "pry"
11
- # Pry.start
12
-
13
- require "irb"
14
- IRB.start(__FILE__)
11
+ require "pry"
12
+ Pry.start
@@ -1,11 +1,8 @@
1
-
2
- lib = File.expand_path("../lib", __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require "graphql_metrics/version"
1
+ require_relative "lib/graphql/metrics/version"
5
2
 
6
3
  Gem::Specification.new do |spec|
7
4
  spec.name = "graphql-metrics"
8
- spec.version = GraphQLMetrics::VERSION
5
+ spec.version = GraphQL::Metrics::VERSION
9
6
  spec.authors = ["Christopher Butcher"]
10
7
  spec.email = ["gems@shopify.com"]
11
8
 
@@ -33,16 +30,19 @@ Gem::Specification.new do |spec|
33
30
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
31
  spec.require_paths = ["lib"]
35
32
 
36
- spec.add_development_dependency "bundler", "~> 1.16"
33
+ spec.add_runtime_dependency "concurrent-ruby", "~> 1.1.0"
34
+ spec.add_runtime_dependency "graphql", "~> 1.9.15"
35
+
36
+ spec.add_development_dependency "bundler", "~> 2.0.2"
37
37
  spec.add_development_dependency "rake", "~> 10.0"
38
38
  spec.add_development_dependency "minitest", "~> 5.0"
39
39
  spec.add_development_dependency 'graphql-batch'
40
- spec.add_development_dependency "graphql", "~> 1.8.2"
41
40
  spec.add_development_dependency "activesupport", "~> 5.1.5"
42
41
  spec.add_development_dependency "pry"
43
42
  spec.add_development_dependency "pry-byebug"
44
43
  spec.add_development_dependency "mocha"
45
44
  spec.add_development_dependency "diffy"
45
+ spec.add_development_dependency "hashdiff"
46
46
  spec.add_development_dependency "fakeredis"
47
47
  spec.add_development_dependency "minitest-focus"
48
48
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "graphql/metrics/version"
5
+ require "graphql/metrics/instrumentation"
6
+ require "graphql/metrics/tracer"
7
+ require "graphql/metrics/analyzer"
8
+
9
+ module GraphQL
10
+ module Metrics
11
+ # The context namespace for all values stored by this gem.
12
+ CONTEXT_NAMESPACE = :graphql_metrics_analysis
13
+
14
+ # Skip metrics capture altogher, by setting `skip_graphql_metrics_analysis: true` in query context.
15
+ SKIP_GRAPHQL_METRICS_ANALYSIS = :skip_graphql_metrics_analysis
16
+
17
+ # Skips just field and argument logging, when query metrics logging is still desirable
18
+ SKIP_FIELD_AND_ARGUMENT_METRICS = :skip_field_and_argument_metrics
19
+
20
+ # Timings related constants.
21
+ TIMINGS_CAPTURE_ENABLED = :timings_capture_enabled
22
+ ANALYZER_INSTANCE_KEY = :analyzer_instance
23
+
24
+ # Context keys to store timings for query phases of execution, field resolver timings.
25
+ QUERY_START_TIME = :query_start_time
26
+ QUERY_START_TIME_MONOTONIC = :query_start_time_monotonic
27
+ PARSING_START_TIME_OFFSET = :parsing_start_time_offset
28
+ PARSING_DURATION = :parsing_duration
29
+ VALIDATION_START_TIME_OFFSET = :validation_start_time_offset
30
+ VALIDATION_DURATION = :validation_duration
31
+ INLINE_FIELD_TIMINGS = :inline_field_timings
32
+ LAZY_FIELD_TIMINGS = :lazy_field_timings
33
+
34
+ def self.timings_capture_enabled?(context)
35
+ return false unless context
36
+ !!context.namespace(CONTEXT_NAMESPACE)[TIMINGS_CAPTURE_ENABLED]
37
+ end
38
+
39
+ def self.current_time
40
+ Process.clock_gettime(Process::CLOCK_REALTIME)
41
+ end
42
+
43
+ def self.current_time_monotonic
44
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ end
46
+
47
+ def self.time(*args)
48
+ TimedResult.new(*args) { yield }
49
+ end
50
+
51
+ class TimedResult
52
+ # NOTE: `time_since_offset` is used to produce start times timed phases of execution (validation, field
53
+ # resolution). These start times are relative to the executed operation's start time, which is captured at the
54
+ # outset of document parsing.
55
+ #
56
+ # The times produced are intentionally similar to:
57
+ # https://github.com/apollographql/apollo-tracing#response-format
58
+ #
59
+ # Taking a field resolver start offset example:
60
+ #
61
+ # < start offset >
62
+ # |------------------|----------|--------->
63
+ # OS (t=0) FS (t=1) FE (t=2)
64
+ #
65
+ # OS = Operation start time
66
+ # FS = Field resolver start time
67
+ # FE = Field resolver end time
68
+ #
69
+ attr_reader :result, :start_time, :duration, :time_since_offset
70
+
71
+ def initialize(offset_time = nil)
72
+ @offset_time = offset_time
73
+ @start_time = GraphQL::Metrics.current_time_monotonic
74
+ @result = yield
75
+ @duration = GraphQL::Metrics.current_time_monotonic - @start_time
76
+ @time_since_offset = @start_time - @offset_time if @offset_time
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Metrics
5
+ class Analyzer < GraphQL::Analysis::AST::Analyzer
6
+ attr_reader :query
7
+
8
+ def initialize(query_or_multiplex)
9
+ super
10
+
11
+ @query = query_or_multiplex
12
+ ns = query.context.namespace(CONTEXT_NAMESPACE)
13
+ ns[ANALYZER_INSTANCE_KEY] = self
14
+
15
+ @static_query_metrics = nil
16
+ @static_field_metrics = []
17
+ end
18
+
19
+ def analyze?
20
+ query.valid? && !query.context[GraphQL::Metrics::SKIP_GRAPHQL_METRICS_ANALYSIS]
21
+ end
22
+
23
+ def extract_query(runtime_query_metrics: {})
24
+ query_extracted(@static_query_metrics.merge(runtime_query_metrics)) if @static_query_metrics
25
+ end
26
+
27
+ def on_enter_operation_definition(_node, _parent, visitor)
28
+ @static_query_metrics = {
29
+ operation_type: visitor.query.selected_operation.operation_type,
30
+ operation_name: visitor.query.selected_operation.name,
31
+ }
32
+ end
33
+
34
+ def on_leave_field(node, _parent, visitor)
35
+ return if visitor.field_definition.introspection?
36
+ return if query.context[SKIP_FIELD_AND_ARGUMENT_METRICS]
37
+
38
+ # NOTE: @rmosolgo "I think it could be reduced to `arguments = visitor.arguments_for(ast_node)`"
39
+ arguments = visitor.arguments_for(node, visitor.field_definition)
40
+ extract_arguments(arguments.argument_values.values, visitor.field_definition)
41
+
42
+ static_metrics = {
43
+ field_name: node.name,
44
+ return_type_name: visitor.type_definition.name,
45
+ parent_type_name: visitor.parent_type_definition.name,
46
+ deprecated: visitor.field_definition.deprecation_reason.present?,
47
+ path: visitor.response_path,
48
+ }
49
+
50
+ if GraphQL::Metrics.timings_capture_enabled?(query.context)
51
+ @static_field_metrics << static_metrics
52
+ else
53
+ field_extracted(static_metrics)
54
+ end
55
+ end
56
+
57
+ def extract_fields_with_runtime_metrics
58
+ return if query.context[SKIP_FIELD_AND_ARGUMENT_METRICS]
59
+
60
+ ns = query.context.namespace(CONTEXT_NAMESPACE)
61
+
62
+ @static_field_metrics.each do |static_metrics|
63
+ resolver_timings = ns[GraphQL::Metrics::INLINE_FIELD_TIMINGS][static_metrics[:path]]
64
+ lazy_field_timings = ns[GraphQL::Metrics::LAZY_FIELD_TIMINGS][static_metrics[:path]]
65
+
66
+ metrics = static_metrics.merge(
67
+ resolver_timings: resolver_timings || [],
68
+ lazy_resolver_timings: lazy_field_timings || [],
69
+ )
70
+
71
+ field_extracted(metrics)
72
+ end
73
+ end
74
+
75
+ def result
76
+ return if GraphQL::Metrics.timings_capture_enabled?(query.context)
77
+ return if query.context[GraphQL::Metrics::SKIP_GRAPHQL_METRICS_ANALYSIS]
78
+
79
+ # NOTE: If we're running as a static analyzer (i.e. not with instrumentation and tracing), we still need to
80
+ # flush static query metrics somewhere other than `after_query`.
81
+ ns = query.context.namespace(CONTEXT_NAMESPACE)
82
+ analyzer = ns[GraphQL::Metrics::ANALYZER_INSTANCE_KEY]
83
+ analyzer.extract_query
84
+ end
85
+
86
+ private
87
+
88
+ def extract_arguments(argument, field_defn, parent_input_object = nil)
89
+ case argument
90
+ when Array
91
+ argument.each do |a|
92
+ extract_arguments(a, field_defn, parent_input_object)
93
+ end
94
+ when Hash
95
+ argument.each_value do |a|
96
+ extract_arguments(a, field_defn, parent_input_object)
97
+ end
98
+ when ::GraphQL::Query::Arguments
99
+ argument.each_value do |arg_val|
100
+ extract_arguments(arg_val, field_defn, parent_input_object)
101
+ end
102
+ when ::GraphQL::Query::Arguments::ArgumentValue
103
+ extract_argument(argument, field_defn, parent_input_object)
104
+ extract_arguments(argument.value, field_defn, parent_input_object)
105
+ when ::GraphQL::Schema::InputObject
106
+ input_object_argument_values = argument.arguments.argument_values.values
107
+ parent_input_object = input_object_argument_values.first&.definition&.metadata&.fetch(:type_class, nil)&.owner
108
+
109
+ extract_arguments(input_object_argument_values, field_defn, parent_input_object)
110
+ end
111
+ end
112
+
113
+ def extract_argument(value, field_defn, parent_input_object = nil)
114
+ static_metrics = {
115
+ argument_name: value.definition.expose_as,
116
+ argument_type_name: value.definition.type.unwrap.to_s,
117
+ parent_field_name: field_defn.name,
118
+ parent_field_type_name: field_defn.metadata[:type_class].owner.graphql_name,
119
+ parent_input_object_type: parent_input_object&.graphql_name,
120
+ default_used: value.default_used?,
121
+ value_is_null: value.value.nil?,
122
+ value: value,
123
+ }
124
+
125
+ argument_extracted(static_metrics)
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Metrics
5
+ class Instrumentation
6
+ def before_query(query)
7
+ unless query_present_and_valid?(query)
8
+ # Setting this prevents Analyzer and Tracer from trying to gather runtime metrics for invalid queries.
9
+ query.context[GraphQL::Metrics::SKIP_GRAPHQL_METRICS_ANALYSIS] = true
10
+ end
11
+
12
+ # Even if queries are present and valid, applications may set this context value in order to opt out of
13
+ # having Analyzer and Tracer gather runtime metrics.
14
+ # If we're skipping runtime metrics, then both Instrumentation before_ and after_query can and should be
15
+ # short-circuited as well.
16
+ return if query.context[GraphQL::Metrics::SKIP_GRAPHQL_METRICS_ANALYSIS]
17
+
18
+ ns = query.context.namespace(CONTEXT_NAMESPACE)
19
+ ns[GraphQL::Metrics::TIMINGS_CAPTURE_ENABLED] = true
20
+ ns[GraphQL::Metrics::INLINE_FIELD_TIMINGS] = {}
21
+ ns[GraphQL::Metrics::LAZY_FIELD_TIMINGS] = {}
22
+ end
23
+
24
+ def after_query(query)
25
+ return if query.context[GraphQL::Metrics::SKIP_GRAPHQL_METRICS_ANALYSIS]
26
+
27
+ ns = query.context.namespace(CONTEXT_NAMESPACE)
28
+
29
+ # NOTE: The start time stored at `ns[GraphQL::Metrics::QUERY_START_TIME_MONOTONIC]` is captured during query
30
+ # parsing, which occurs before `Instrumentation#before_query`.
31
+ query_duration = GraphQL::Metrics.current_time_monotonic - ns[GraphQL::Metrics::QUERY_START_TIME_MONOTONIC]
32
+
33
+ runtime_query_metrics = {
34
+ query_start_time: ns[GraphQL::Metrics::QUERY_START_TIME],
35
+ query_duration: query_duration,
36
+ parsing_start_time_offset: ns[GraphQL::Metrics::PARSING_START_TIME_OFFSET],
37
+ parsing_duration: ns[GraphQL::Metrics::PARSING_DURATION],
38
+ validation_start_time_offset: ns[GraphQL::Metrics::VALIDATION_START_TIME_OFFSET],
39
+ validation_duration: ns[GraphQL::Metrics::VALIDATION_DURATION],
40
+ }
41
+
42
+ analyzer = ns[GraphQL::Metrics::ANALYZER_INSTANCE_KEY]
43
+ analyzer.extract_fields_with_runtime_metrics
44
+ analyzer.extract_query(runtime_query_metrics: runtime_query_metrics)
45
+ end
46
+
47
+ private
48
+
49
+ def query_present_and_valid?(query)
50
+ query.valid? && query.document.to_query_string.present?
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Metrics
5
+ class Tracer
6
+ # NOTE: These constants come from the graphql ruby gem.
7
+ GRAPHQL_GEM_LEXING_KEY = 'lex'
8
+ GRAPHQL_GEM_PARSING_KEY = 'parse'
9
+ GRAPHQL_GEM_VALIDATION_KEYS = ['validate', 'analyze_query']
10
+ GRAPHQL_GEM_TRACING_FIELD_KEYS = [
11
+ GRAPHQL_GEM_TRACING_FIELD_KEY = 'execute_field',
12
+ GRAPHQL_GEM_TRACING_LAZY_FIELD_KEY = 'execute_field_lazy'
13
+ ]
14
+
15
+ def trace(key, data, &block)
16
+ # NOTE: Context doesn't exist yet during lexing, parsing.
17
+ possible_context = data[:query]&.context
18
+
19
+ skip_tracing = possible_context&.fetch(GraphQL::Metrics::SKIP_GRAPHQL_METRICS_ANALYSIS, false)
20
+ return yield if skip_tracing
21
+
22
+ # NOTE: Not all tracing events are handled here, but those that are are handled in this case statement in
23
+ # chronological order.
24
+ case key
25
+ when GRAPHQL_GEM_LEXING_KEY
26
+ return setup_tracing_before_lexing(&block)
27
+ when GRAPHQL_GEM_PARSING_KEY
28
+ return capture_parsing_time(&block)
29
+ when *GRAPHQL_GEM_VALIDATION_KEYS
30
+ context = possible_context
31
+
32
+ return yield unless context.query.valid?
33
+ return capture_validation_time(context, &block)
34
+ when *GRAPHQL_GEM_TRACING_FIELD_KEYS
35
+ return yield if data[:query].context[SKIP_FIELD_AND_ARGUMENT_METRICS]
36
+ return yield unless GraphQL::Metrics.timings_capture_enabled?(data[:query].context)
37
+
38
+ pre_context = nil
39
+
40
+ context_key = case key
41
+ when GRAPHQL_GEM_TRACING_FIELD_KEY
42
+ GraphQL::Metrics::INLINE_FIELD_TIMINGS
43
+ when GRAPHQL_GEM_TRACING_LAZY_FIELD_KEY
44
+ GraphQL::Metrics::LAZY_FIELD_TIMINGS
45
+ end
46
+
47
+ trace_field(context_key, data, &block)
48
+ else
49
+ return yield
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def pre_context
56
+ # NOTE: This is used to store timings from lexing, parsing, validation, before we have a context to store
57
+ # values in. Uses thread-safe Concurrent::ThreadLocalVar to store a set of values per thread.
58
+ @pre_context ||= Concurrent::ThreadLocalVar.new(OpenStruct.new)
59
+ end
60
+
61
+ def setup_tracing_before_lexing
62
+ pre_context.value.query_start_time = GraphQL::Metrics.current_time
63
+ pre_context.value.query_start_time_monotonic = GraphQL::Metrics.current_time_monotonic
64
+
65
+ yield
66
+ end
67
+
68
+ def capture_parsing_time
69
+ timed_result = GraphQL::Metrics.time { yield }
70
+
71
+ pre_context.value.parsing_start_time_offset = timed_result.start_time
72
+ pre_context.value.parsing_duration = timed_result.duration
73
+
74
+ timed_result.result
75
+ end
76
+
77
+ def capture_validation_time(context)
78
+ timed_result = GraphQL::Metrics.time(pre_context.value.query_start_time_monotonic) { yield }
79
+
80
+ ns = context.namespace(CONTEXT_NAMESPACE)
81
+ previous_validation_duration = ns[GraphQL::Metrics::VALIDATION_DURATION] || 0
82
+
83
+ ns[QUERY_START_TIME] = pre_context.value.query_start_time
84
+ ns[QUERY_START_TIME_MONOTONIC] = pre_context.value.query_start_time_monotonic
85
+ ns[PARSING_START_TIME_OFFSET] = pre_context.value.parsing_start_time_offset
86
+ ns[PARSING_DURATION] = pre_context.value.parsing_duration
87
+ ns[VALIDATION_START_TIME_OFFSET] = timed_result.time_since_offset
88
+ ns[VALIDATION_DURATION] = timed_result.duration + previous_validation_duration
89
+
90
+ timed_result.result
91
+ end
92
+
93
+ def trace_field(context_key, data)
94
+ ns = data[:query].context.namespace(CONTEXT_NAMESPACE)
95
+ query_start_time_monotonic = ns[GraphQL::Metrics::QUERY_START_TIME_MONOTONIC]
96
+
97
+ timed_result = GraphQL::Metrics.time(query_start_time_monotonic) { yield }
98
+
99
+ path_excluding_numeric_indicies = data[:path].select { |p| p.is_a?(String) }
100
+ ns[context_key][path_excluding_numeric_indicies] ||= []
101
+ ns[context_key][path_excluding_numeric_indicies] << {
102
+ start_time_offset: timed_result.time_since_offset, duration: timed_result.duration
103
+ }
104
+
105
+ timed_result.result
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Metrics
5
+ VERSION = "3.0.0"
6
+ end
7
+ end