graphql-metrics 2.0.1 → 3.0.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.
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