jpie 2.0.0 → 2.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a598f58a7c0f80730e852f069276ea9d3066d3c7622a8faec599f1b58039f0e
4
- data.tar.gz: 33292a3d2e2b98c09fb9868972b2936738ebf00b6ec0a1b7baf46ec51b017369
3
+ metadata.gz: 1477d2959b9a77a44758b503d4ef674cc72c8bc024fc533673ed699768e2e5a7
4
+ data.tar.gz: 0db30ba17f5bc3d70f1a531062fc0162ba3bcfe9347c5ec48aebcc1b796ba3a9
5
5
  SHA512:
6
- metadata.gz: 5b3830b35c1fc6690c755c5f90ad7568ffc6c57d7b8ad0516fdf368f476a15223a0489694acb5ac288d2c5f8f9f8889c154f5e0f09915dfac276b876ed437858
7
- data.tar.gz: 0cdc51e099411e4f27342783e3e47bf5e34ff983308758fa904819009df8f23364521a7d1b32a634827fdcba7f4302501202ef4f0c193d119e97b9f9daaf7990
6
+ metadata.gz: d927ad5ed8efe663829590f423b7f940578afa76a15d0e4ac64cbef89b615a57a6eaa8d4d46140cffe399e4b7c1a067f8e7d1d8a19981b037f0d9bee9f29f050
7
+ data.tar.gz: 1d9ca2c4e17a36c220a78d92bbdd9014cd924add0c3d194378fabdb5037bdc0be54461f05ca9a33bf5163ea6b68d7e36b052a6221c20cc9aed6b74d2445e15b1
data/.rubocop.yml CHANGED
@@ -107,3 +107,4 @@ RSpec/NestedGroups:
107
107
  # Allow more memoized helpers in complex specs
108
108
  RSpec/MultipleMemoizedHelpers:
109
109
  Max: 8
110
+
data/Gemfile.lock CHANGED
@@ -1,9 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jpie (2.0.0)
5
- actionpack (~> 8.0, >= 8.0.0)
6
- rails (~> 8.0, >= 8.0.0)
4
+ jpie (2.0.2)
5
+ actionpack (~> 8.1, >= 8.1.0)
6
+ pg_query (>= 4)
7
+ prosopite (>= 1)
8
+ rails (~> 8.1, >= 8.1.0)
7
9
 
8
10
  GEM
9
11
  remote: https://rubygems.org/
@@ -101,6 +103,27 @@ GEM
101
103
  erubi (1.13.1)
102
104
  globalid (1.3.0)
103
105
  activesupport (>= 6.1)
106
+ google-protobuf (4.33.5)
107
+ bigdecimal
108
+ rake (>= 13)
109
+ google-protobuf (4.33.5-aarch64-linux-gnu)
110
+ bigdecimal
111
+ rake (>= 13)
112
+ google-protobuf (4.33.5-aarch64-linux-musl)
113
+ bigdecimal
114
+ rake (>= 13)
115
+ google-protobuf (4.33.5-arm64-darwin)
116
+ bigdecimal
117
+ rake (>= 13)
118
+ google-protobuf (4.33.5-x86_64-darwin)
119
+ bigdecimal
120
+ rake (>= 13)
121
+ google-protobuf (4.33.5-x86_64-linux-gnu)
122
+ bigdecimal
123
+ rake (>= 13)
124
+ google-protobuf (4.33.5-x86_64-linux-musl)
125
+ bigdecimal
126
+ rake (>= 13)
104
127
  hana (1.3.7)
105
128
  i18n (1.14.7)
106
129
  concurrent-ruby (~> 1.0)
@@ -160,10 +183,13 @@ GEM
160
183
  parser (3.3.10.0)
161
184
  ast (~> 2.4.1)
162
185
  racc
186
+ pg_query (6.2.2)
187
+ google-protobuf (>= 3.25.3)
163
188
  pp (0.6.3)
164
189
  prettyprint
165
190
  prettyprint (0.2.0)
166
191
  prism (1.6.0)
192
+ prosopite (2.1.2)
167
193
  psych (5.2.6)
168
194
  date
169
195
  stringio
data/README.md CHANGED
@@ -25,7 +25,7 @@ bundle add jpie
25
25
  ## Requirements
26
26
 
27
27
  - Ruby >= 3.4.0
28
- - Rails >= 8.0.0
28
+ - Rails >= 8.1.0
29
29
 
30
30
  ## Routing
31
31
 
@@ -659,26 +659,152 @@ JSONAPI.configure do |config|
659
659
  end
660
660
  ```
661
661
 
662
- ### N+1 Query Warning
662
+ ### N+1 Detection (Rails 8.1+)
663
663
 
664
- When clients omit the `include` parameter for nested relationships, the API may execute N+1 queries. The gem detects high query counts and:
664
+ The gem uses [Prosopite](https://github.com/charkost/prosopite) to detect real N+1 query patterns on JSON:API requests. When N+1 is detected, it emits a `jpie.n1_detected` event via `Rails.event.notify`. When `n1_detection_enabled` is true, a built-in subscriber logs to `Rails.logger.warn` automatically—no setup required.
665
665
 
666
- 1. **Logs** a warning to `Rails.logger` with path, include param, and query count
667
- 2. **Adds response headers** so clients can detect and fix their requests:
668
- - `X-JPie-Query-Count` – number of DB queries executed
669
- - `Server-Timing` – W3C standard header with query count in `desc`
670
- - `X-JPie-Performance-Warning` – when count exceeds threshold, suggests adding `include` param
666
+ **Event:** `jpie.n1_detected`
671
667
 
672
- Configure in an initializer:
668
+ **Payload:**
669
+
670
+ ```ruby
671
+ {
672
+ path: "/users",
673
+ method: "GET",
674
+ include: nil,
675
+ resource_type: "users",
676
+ resource_id: nil,
677
+ n1_details: "N+1 queries detected:\n SELECT ... FROM users WHERE id = ?\nCall stack:\n app/...",
678
+ n1_query: "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"id\" = 1 LIMIT 1", # First duplicate query
679
+ correlation_id: Current.cid # When config.correlation_id_resolver = -> { Current.cid }
680
+ }
681
+ ```
682
+
683
+ **Custom subscriber (e.g. for APM):**
684
+
685
+ ```ruby
686
+ if Rails.respond_to?(:event)
687
+ Rails.event.subscribe(Class.new do
688
+ def emit(event)
689
+ ev = event.is_a?(Hash) ? event : event.to_h
690
+ return unless ev[:name] == "jpie.n1_detected"
691
+
692
+ payload = ev[:payload] || {}
693
+ # Forward to Datadog, AppSignal, New Relic, etc.
694
+ end
695
+ end.new)
696
+ end
697
+ ```
698
+
699
+ **Configuration:**
700
+
701
+ ```ruby
702
+ JSONAPI.configure do |config|
703
+ config.n1_detection_enabled = true # Override: default is !Rails.env.production?
704
+ end
705
+ ```
706
+
707
+ Default: enabled in development and test, disabled in production. Prosopite options (e.g. `Prosopite.rails_logger`, `Prosopite.raise`) remain under app control; the gem only sets `custom_logger` for instrumentation.
708
+
709
+ ### Query Tracking (Slow Query & Excessive Count) (Rails 8.1+)
710
+
711
+ The gem detects slow SQL queries and excessive query count per JSON:API request. When a single query exceeds the duration threshold or total queries exceed the count threshold, it emits `jpie.slow_query_detected` or `jpie.excessive_queries_detected` via `Rails.event.notify`. When `query_tracking_enabled` is true, a built-in subscriber logs to `Rails.logger.warn` automatically—no setup required.
712
+
713
+ **Events:** `jpie.slow_query_detected`, `jpie.excessive_queries_detected`
714
+
715
+ **Payloads:**
716
+
717
+ ```ruby
718
+ # jpie.slow_query_detected
719
+ {
720
+ path: "/users",
721
+ method: "GET",
722
+ include: nil,
723
+ resource_type: "users",
724
+ resource_id: nil,
725
+ sql: "SELECT \"users\".* FROM \"users\" WHERE ...",
726
+ duration_ms: 523.4,
727
+ threshold_ms: 200,
728
+ correlation_id: Current.cid # When config.correlation_id_resolver = -> { Current.cid }
729
+ }
730
+
731
+ # jpie.excessive_queries_detected
732
+ {
733
+ path: "/users",
734
+ method: "GET",
735
+ include: nil,
736
+ resource_type: "users",
737
+ resource_id: nil,
738
+ query_count: 127,
739
+ threshold: 10,
740
+ queries: ["SELECT ...", "SELECT ...", ...], # up to query_tracking_queries_cap
741
+ correlation_id: Current.cid # When config.correlation_id_resolver = -> { Current.cid }
742
+ }
743
+ ```
744
+
745
+ **Custom subscriber (e.g. for APM):**
746
+
747
+ ```ruby
748
+ if Rails.respond_to?(:event)
749
+ Rails.event.subscribe(Class.new do
750
+ def emit(event)
751
+ ev = event.is_a?(Hash) ? event : event.to_h
752
+ return unless %w[jpie.slow_query_detected jpie.excessive_queries_detected].include?(ev[:name])
753
+
754
+ payload = ev[:payload] || {}
755
+ # Forward to Datadog, AppSignal, New Relic, etc.
756
+ end
757
+ end.new)
758
+ end
759
+ ```
760
+
761
+ **Configuration:**
762
+
763
+ ```ruby
764
+ JSONAPI.configure do |config|
765
+ config.query_tracking_enabled = true
766
+ config.query_count_threshold = 10
767
+ config.slow_query_threshold_ms = 200
768
+ config.query_tracking_queries_cap = 100
769
+ end
770
+ ```
771
+
772
+ Default: enabled in development and test, disabled in production. Thresholds are configurable.
773
+
774
+ ### Correlation ID
775
+
776
+ When set, `correlation_id_resolver` is a proc that returns the current request's correlation ID (string or nil). The gem adds `correlation_id` to all JPie event payloads (`jpie.n1_detected`, `jpie.slow_query_detected`, `jpie.excessive_queries_detected`) and tags JPie log output with `CID(...)` when present.
777
+
778
+ **Configuration:**
779
+
780
+ ```ruby
781
+ JSONAPI.configure do |config|
782
+ config.correlation_id_resolver = -> { Current.cid }
783
+ end
784
+ ```
785
+
786
+ Default: `nil` (no correlation ID in payloads or logs).
787
+
788
+ ### Response Headers (Development)
789
+
790
+ When `jpie_headers_enabled` is true (default: development and test only), the gem adds performance headers to JSON:API responses so frontends can surface warnings in dev tools.
791
+
792
+ **Headers:**
793
+
794
+ | Header | When Set | Example |
795
+ |--------|----------|---------|
796
+ | `X-JPie-Query-Count` | Always (when query tracking + headers enabled) | `42` |
797
+ | `X-JPie-Performance-Warning` | Only when any event fired (n1, excessive_queries, slow_query) | `n1,excessive_queries` |
798
+
799
+ **Configuration:**
673
800
 
674
801
  ```ruby
675
802
  JSONAPI.configure do |config|
676
- config.n1_query_threshold = 20 # Warn when queries exceed this (default: 20)
677
- config.n1_warning_enabled = true # Set false to disable (default: true)
803
+ config.jpie_headers_enabled = true # Override: default is !Rails.env.production?
678
804
  end
679
805
  ```
680
806
 
681
- Clients can read `X-JPie-Query-Count` or `Server-Timing` to monitor performance and add appropriate `include` values when the warning header appears.
807
+ Default: enabled in development and test, disabled in production. Ensure CORS exposes these headers if your frontend runs on a different origin.
682
808
 
683
809
  ### Base Controller Class
684
810
 
data/jpie.gemspec CHANGED
@@ -24,8 +24,10 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.required_ruby_version = ">= 3.4.0"
26
26
 
27
- spec.add_dependency "actionpack", "~> 8.0", ">= 8.0.0"
28
- spec.add_dependency "rails", "~> 8.0", ">= 8.0.0"
27
+ spec.add_dependency "actionpack", "~> 8.1", ">= 8.1.0"
28
+ spec.add_dependency "pg_query", ">= 4"
29
+ spec.add_dependency "prosopite", ">= 1"
30
+ spec.add_dependency "rails", "~> 8.1", ">= 8.1.0"
29
31
 
30
32
  spec.metadata["rubygems_mfa_required"] = "true"
31
33
  end
@@ -5,7 +5,12 @@ module JSONAPI
5
5
  attr_accessor :default_page_size, :max_page_size, :jsonapi_meta, :authorization_handler,
6
6
  :authorization_scope, :document_meta_resolver,
7
7
  :namespace_type_format, :namespace_model_mapping, :namespace_fallback,
8
- :n1_query_threshold, :n1_warning_enabled
8
+ :n1_detection_enabled,
9
+ :query_tracking_enabled, :query_count_threshold, :slow_query_threshold_ms,
10
+ :query_tracking_queries_cap,
11
+ :jpie_headers_enabled,
12
+ :correlation_id_resolver,
13
+ :variant_failure_fallback
9
14
 
10
15
  def initialize
11
16
  set_defaults
@@ -46,7 +51,8 @@ module JSONAPI
46
51
 
47
52
  def set_defaults
48
53
  set_pagination_defaults
49
- set_n1_defaults
54
+ set_n1_detection_defaults
55
+ set_query_tracking_defaults
50
56
  set_nil_defaults
51
57
  @document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
52
58
  @base_controller_class = "ActionController::API"
@@ -58,15 +64,24 @@ module JSONAPI
58
64
  @max_page_size = 100
59
65
  end
60
66
 
61
- def set_n1_defaults
62
- @n1_query_threshold = 20
63
- @n1_warning_enabled = true
67
+ def set_n1_detection_defaults
68
+ @n1_detection_enabled = !defined?(Rails) || !Rails.env.production?
69
+ end
70
+
71
+ def set_query_tracking_defaults
72
+ @query_tracking_enabled = !defined?(Rails) || !Rails.env.production?
73
+ @query_count_threshold = 10
74
+ @slow_query_threshold_ms = 200
75
+ @query_tracking_queries_cap = 100
76
+ @jpie_headers_enabled = !defined?(Rails) || !Rails.env.production?
64
77
  end
65
78
 
66
79
  def set_nil_defaults
67
80
  @jsonapi_meta = nil
68
81
  @authorization_handler = nil
69
82
  @authorization_scope = nil
83
+ @correlation_id_resolver = nil
84
+ @variant_failure_fallback = :original
70
85
  end
71
86
 
72
87
  def set_namespace_defaults
@@ -22,15 +22,6 @@ module JSONAPI
22
22
  render_sort_errors(invalid) if invalid.any?
23
23
  end
24
24
 
25
- def validate_include_param
26
- includes = parse_include_param
27
- return if includes.empty?
28
-
29
- permitted = @resource_class.relationship_names.map(&:to_s)
30
- invalid = includes.reject { |p| include_path_valid?(p, permitted) }
31
- render_include_errors(invalid) if invalid.any?
32
- end
33
-
34
25
  private
35
26
 
36
27
  def first_invalid_field(fields)
@@ -62,27 +53,6 @@ module JSONAPI
62
53
  invalid.any? ? { type: resource_type.to_s, invalid: } : nil
63
54
  end
64
55
 
65
- def include_path_valid?(path, permitted)
66
- parts = path.split(".")
67
- return false unless permitted.include?(parts.first)
68
-
69
- nested_path_valid?(parts)
70
- end
71
-
72
- def nested_path_valid?(parts)
73
- current = model_class
74
- parts.each do |name|
75
- return true if self.class.active_storage_attachment?(name, current)
76
-
77
- assoc = current.reflect_on_association(name.to_sym)
78
- return false unless assoc
79
- break if assoc.polymorphic?
80
-
81
- current = assoc.klass
82
- end
83
- true
84
- end
85
-
86
56
  def render_field_error(error)
87
57
  render_parameter_errors(
88
58
  error[:invalid],
@@ -100,15 +70,6 @@ module JSONAPI
100
70
  source_proc: ->(_) { { parameter: "sort" } },
101
71
  )
102
72
  end
103
-
104
- def render_include_errors(invalid)
105
- render_parameter_errors(
106
- invalid,
107
- title: "Invalid Include Path",
108
- detail_proc: ->(p) { "Invalid include path requested: #{p}" },
109
- source_proc: ->(_) { { parameter: "include" } },
110
- )
111
- end
112
73
  end
113
74
  end
114
75
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module ResourceActions
5
+ module IncludePreloading
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def includes_to_hash(paths)
11
+ hash = paths.each_with_object({}) do |path, h|
12
+ path.split(".").reduce(h) { |cur, part| cur[part.to_sym] ||= {} }
13
+ end
14
+ filter_includable(hash, model_class)
15
+ end
16
+
17
+ # Preloads associations from the include param so the serializer avoids N+1 queries
18
+ # when walking include paths (association.loaded? is true). Used by both show and index.
19
+ def scope_with_includes(scope)
20
+ includes = parse_include_param
21
+ return scope unless includes.any?
22
+
23
+ inc_hash = includes_to_hash(includes)
24
+ hash_contains_polymorphic?(inc_hash, model_class) ? scope.preload(inc_hash) : scope.includes(inc_hash)
25
+ end
26
+
27
+ def filter_includable(hash, klass)
28
+ hash.each_with_object({}) do |(key, value), filtered|
29
+ assoc = klass.reflect_on_association(key)
30
+ next unless assoc
31
+
32
+ filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
33
+ end
34
+ end
35
+
36
+ def hash_contains_polymorphic?(hash, klass)
37
+ hash.any? { |key, value| polymorphic_in_hash_entry?(key, value, klass) }
38
+ end
39
+
40
+ def polymorphic_in_hash_entry?(key, value, klass)
41
+ assoc = klass.reflect_on_association(key)
42
+ return false unless assoc
43
+
44
+ assoc.polymorphic? || (value.present? && hash_contains_polymorphic?(value, assoc.klass))
45
+ end
46
+
47
+ def preload_included_resource_associations(resources, includes)
48
+ return if includes.empty? || resources.empty?
49
+
50
+ includes.each do |include_path|
51
+ association_name = include_path.split(".").first.to_sym
52
+ assoc_reflection = model_class.reflect_on_association(association_name)
53
+ next unless assoc_reflection
54
+
55
+ targets = collect_include_targets(resources, association_name)
56
+ next if targets.empty?
57
+
58
+ apply_preloads_for_targets(targets, assoc_reflection.polymorphic?)
59
+ end
60
+ end
61
+
62
+ def apply_preloads_for_targets(targets, polymorphic)
63
+ if polymorphic
64
+ targets.group_by(&:class).each_value { |recs| apply_resource_preloads(recs) }
65
+ else
66
+ apply_resource_preloads(targets)
67
+ end
68
+ end
69
+
70
+ def collect_include_targets(resources, association_name)
71
+ resources.flat_map do |r|
72
+ target = r.association(association_name).target
73
+ target.respond_to?(:to_a) ? target.to_a : Array(target)
74
+ end.compact.uniq
75
+ end
76
+
77
+ def apply_resource_preloads(records)
78
+ return if records.empty?
79
+
80
+ klass = records.first.class
81
+ resource_scope = ResourceLoader.find_for_model(klass).records
82
+ preload_values = resource_scope.preload_values + resource_scope.includes_values
83
+ return if preload_values.empty?
84
+
85
+ ActiveRecord::Associations::Preloader.new(records:, associations: preload_values).call
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module ResourceActions
5
+ module IncludeValidation
6
+ extend ActiveSupport::Concern
7
+
8
+ def validate_include_param
9
+ includes = parse_include_param
10
+ return if includes.empty?
11
+
12
+ permitted = @resource_class.relationship_names.map(&:to_s)
13
+ invalid = includes.reject { |p| include_path_valid?(p, permitted) }
14
+ render_include_errors(invalid) if invalid.any?
15
+ end
16
+
17
+ private
18
+
19
+ def include_path_valid?(path, permitted)
20
+ parts = path.split(".")
21
+ return false unless permitted.include?(parts.first)
22
+
23
+ nested_path_valid?(parts)
24
+ end
25
+
26
+ def nested_path_valid?(parts)
27
+ current = model_class
28
+ parts.each do |name|
29
+ return true if self.class.active_storage_attachment?(name, current)
30
+
31
+ assoc = current.reflect_on_association(name.to_sym)
32
+ return false unless assoc
33
+ break if assoc.polymorphic?
34
+
35
+ current = assoc.klass
36
+ end
37
+ true
38
+ end
39
+
40
+ def render_include_errors(invalid)
41
+ render_parameter_errors(
42
+ invalid,
43
+ title: "Invalid Include Path",
44
+ detail_proc: ->(p) { "Invalid include path requested: #{p}" },
45
+ source_proc: ->(_) { { parameter: "include" } },
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
@@ -4,6 +4,7 @@ module JSONAPI
4
4
  module ResourceActions
5
5
  module Serialization
6
6
  extend ActiveSupport::Concern
7
+ include IncludePreloading
7
8
 
8
9
  def serialize_resource(resource)
9
10
  JSONAPI::Serializer.new(resource).to_hash(
@@ -19,6 +20,8 @@ module JSONAPI
19
20
  resources = scope_with_includes(resources)
20
21
  resources_array = resources.to_a
21
22
 
23
+ preload_included_resource_associations(resources_array, includes)
24
+
22
25
  data, all_included = serialize_resources_with_includes(resources_array, includes, fields)
23
26
  build_collection_response(data, all_included)
24
27
  end
@@ -56,43 +59,6 @@ module JSONAPI
56
59
  processed.add(key)
57
60
  end
58
61
 
59
- def includes_to_hash(paths)
60
- hash = paths.each_with_object({}) do |path, h|
61
- path.split(".").reduce(h) { |cur, part| cur[part.to_sym] ||= {} }
62
- end
63
- filter_includable(hash, model_class)
64
- end
65
-
66
- # Preloads associations from the include param so the serializer avoids N+1 queries
67
- # when walking include paths (association.loaded? is true). Used by both show and index.
68
- def scope_with_includes(scope)
69
- includes = parse_include_param
70
- return scope unless includes.any?
71
-
72
- inc_hash = includes_to_hash(includes)
73
- hash_contains_polymorphic?(inc_hash, model_class) ? scope.preload(inc_hash) : scope.includes(inc_hash)
74
- end
75
-
76
- def filter_includable(hash, klass)
77
- hash.each_with_object({}) do |(key, value), filtered|
78
- assoc = klass.reflect_on_association(key)
79
- next unless assoc
80
-
81
- filtered[key] = value.empty? || assoc.polymorphic? ? value : filter_includable(value, assoc.klass)
82
- end
83
- end
84
-
85
- def hash_contains_polymorphic?(hash, klass)
86
- hash.any? { |key, value| polymorphic_in_hash_entry?(key, value, klass) }
87
- end
88
-
89
- def polymorphic_in_hash_entry?(key, value, klass)
90
- assoc = klass.reflect_on_association(key)
91
- return false unless assoc
92
-
93
- assoc.polymorphic? || (value.present? && hash_contains_polymorphic?(value, assoc.klass))
94
- end
95
-
96
62
  def build_collection_response(data, all_included)
97
63
  result = { jsonapi: JSONAPI::Serializer.jsonapi_object, data: }
98
64
  result[:included] = all_included if all_included.any?
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "resource_actions/filter_validation"
4
4
  require_relative "resource_actions/field_validation"
5
+ require_relative "resource_actions/include_validation"
6
+ require_relative "resource_actions/include_preloading"
5
7
  require_relative "resource_actions/serialization"
6
8
  require_relative "resource_actions/pagination"
7
9
  require_relative "resource_actions/type_validation"
@@ -14,6 +16,7 @@ module JSONAPI
14
16
  include ActiveStorageSupport
15
17
  include FilterValidation
16
18
  include FieldValidation
19
+ include IncludeValidation
17
20
  include Serialization
18
21
  include Pagination
19
22
  include TypeValidation
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prosopite"
4
+
5
+ module JSONAPI
6
+ module Rack
7
+ class N1Detection
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) unless JSONAPI.configuration.n1_detection_enabled
14
+ return @app.call(env) unless jsonapi_request?(env)
15
+ return @app.call(env) unless postgres?
16
+
17
+ Thread.current[:jpie_request_env] = env
18
+ Prosopite.custom_logger = JSONAPI::Support::ProsopiteInstrumentationLogger.new
19
+
20
+ result = Prosopite.scan { @app.call(env) }
21
+ result
22
+ ensure
23
+ Prosopite.finish
24
+ Thread.current[:jpie_request_env] = nil
25
+ end
26
+
27
+ private
28
+
29
+ def jsonapi_request?(env)
30
+ env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
31
+ end
32
+
33
+ def postgres?
34
+ return false unless defined?(ActiveRecord) && ActiveRecord::Base.connection_db_config
35
+
36
+ adapter = ActiveRecord::Base.connection_db_config.adapter
37
+ adapter.to_s.include?("postgresql")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Rack
5
+ class QueryTracking
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ return @app.call(env) unless JSONAPI.configuration.query_tracking_enabled
12
+ return @app.call(env) unless jsonapi_request?(env)
13
+
14
+ call_with_tracking(env)
15
+ ensure
16
+ emit_excessive_queries_if_needed
17
+ Thread.current[:jpie_query_tracking] = nil
18
+ Thread.current[:jpie_warnings] = nil
19
+ end
20
+
21
+ private
22
+
23
+ def call_with_tracking(env)
24
+ Thread.current[:jpie_query_tracking] = { count: 0, queries: [], env: env }
25
+ Thread.current[:jpie_warnings] = [] if JSONAPI.configuration.jpie_headers_enabled
26
+
27
+ status, headers, body = @app.call(env)
28
+ add_jpie_headers(headers) if JSONAPI.configuration.jpie_headers_enabled
29
+
30
+ [status, headers, body]
31
+ end
32
+
33
+ def jsonapi_request?(env)
34
+ env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
35
+ end
36
+
37
+ def emit_excessive_queries_if_needed
38
+ tracking = Thread.current[:jpie_query_tracking]
39
+ return if tracking.nil?
40
+
41
+ count = tracking[:count]
42
+ threshold = JSONAPI.configuration.query_count_threshold
43
+ return if count <= threshold
44
+ return unless defined?(Rails) && Rails.respond_to?(:event)
45
+
46
+ notify_excessive_queries(tracking, count, threshold)
47
+ end
48
+
49
+ def notify_excessive_queries(tracking, count, threshold)
50
+ cap = JSONAPI.configuration.query_tracking_queries_cap
51
+ queries = tracking[:queries].first(cap)
52
+ payload = build_excessive_queries_payload(tracking[:env], count, threshold, queries)
53
+
54
+ Rails.event.tagged("jsonapi", "excessive_queries") do
55
+ Rails.event.notify("jpie.excessive_queries_detected", payload)
56
+ end
57
+ end
58
+
59
+ def build_excessive_queries_payload(env, count, threshold, queries)
60
+ build_payload(env).merge(
61
+ query_count: count,
62
+ threshold: threshold,
63
+ queries: queries,
64
+ )
65
+ end
66
+
67
+ def build_payload(env)
68
+ base = env.nil? ? {} : request_payload_from_env(env)
69
+ base.merge(correlation_id: JSONAPI::Support::CorrelationId.resolve)
70
+ end
71
+
72
+ def request_payload_from_env(env)
73
+ req = ActionDispatch::Request.new(env)
74
+ params = req.params
75
+ {
76
+ path: req.path,
77
+ method: req.request_method,
78
+ include: params[:include],
79
+ resource_type: params[:resource_type],
80
+ resource_id: params[:id],
81
+ }
82
+ end
83
+
84
+ def add_jpie_headers(headers)
85
+ tracking = Thread.current[:jpie_query_tracking]
86
+ return if tracking.nil?
87
+
88
+ count = tracking[:count]
89
+ headers["X-JPie-Query-Count"] = count.to_s
90
+
91
+ warning_str = build_performance_warning_header(count)
92
+ headers["X-JPie-Performance-Warning"] = warning_str if warning_str.present?
93
+ end
94
+
95
+ def build_performance_warning_header(count)
96
+ warnings = (Thread.current[:jpie_warnings] || []).dup
97
+ warnings << "excessive_queries" if count > JSONAPI.configuration.query_count_threshold
98
+ warnings.uniq.join(",").presence
99
+ end
100
+ end
101
+ end
102
+ end
@@ -16,14 +16,40 @@ module JSONAPI
16
16
  Mime::Type.register "application/vnd.api+json", :jsonapi
17
17
  end
18
18
 
19
- initializer "json_api.n1_warning_middleware", after: "action_dispatch.configure" do |app|
19
+ initializer "json_api.n1_log_subscriber", after: "action_dispatch.configure" do
20
+ if Rails.respond_to?(:event) && JSONAPI.configuration.n1_detection_enabled
21
+ Rails.event.subscribe(JSONAPI::Support::N1LogSubscriber.new)
22
+ end
23
+ end
24
+
25
+ initializer "json_api.n1_detection_middleware", after: "action_dispatch.configure" do |app|
20
26
  if app.config.respond_to?(:server_timing) && app.config.server_timing
21
- app.config.middleware.insert_after ActionDispatch::ServerTiming, JSONAPI::Rack::N1Warning
27
+ app.config.middleware.insert_after ActionDispatch::ServerTiming, JSONAPI::Rack::N1Detection
22
28
  else
23
- app.config.middleware.use JSONAPI::Rack::N1Warning
29
+ app.config.middleware.use JSONAPI::Rack::N1Detection
24
30
  end
25
31
  end
26
32
 
33
+ initializer "json_api.query_tracking_subscriber", after: "action_dispatch.configure" do
34
+ ActiveSupport::Notifications.subscribe("sql.active_record", JSONAPI::Support::QueryTrackingSubscriber.new)
35
+ end
36
+
37
+ initializer "json_api.query_tracking_log_subscriber", after: "action_dispatch.configure" do
38
+ if Rails.respond_to?(:event) && JSONAPI.configuration.query_tracking_enabled
39
+ Rails.event.subscribe(JSONAPI::Support::QueryTrackingLogSubscriber.new)
40
+ end
41
+ end
42
+
43
+ initializer "json_api.header_warning_subscriber", after: "action_dispatch.configure" do
44
+ if Rails.respond_to?(:event) && JSONAPI.configuration.jpie_headers_enabled
45
+ Rails.event.subscribe(JSONAPI::Support::HeaderWarningSubscriber.new)
46
+ end
47
+ end
48
+
49
+ initializer "json_api.query_tracking_middleware", after: "json_api.n1_detection_middleware" do |app|
50
+ app.config.middleware.insert_after JSONAPI::Rack::N1Detection, JSONAPI::Rack::QueryTracking
51
+ end
52
+
27
53
  initializer "json_api.routes" do |_app|
28
54
  require "json_api/routing"
29
55
  ActionDispatch::Routing::Mapper.include JSONAPI::Routing
@@ -17,8 +17,15 @@ module JSONAPI
17
17
 
18
18
  def add_active_storage_download_link(links)
19
19
  links[:download] = variant_or_blob_path || fallback_blob_path
20
- rescue StandardError
21
- links[:download] = fallback_blob_path
20
+ rescue StandardError => e
21
+ case JSONAPI.configuration.variant_failure_fallback
22
+ when :original
23
+ links[:download] = fallback_blob_path
24
+ when :none
25
+ # omit download link
26
+ when :exception
27
+ raise e
28
+ end
22
29
  end
23
30
 
24
31
  def variant_or_blob_path
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ module CorrelationId
6
+ def self.resolve
7
+ resolver = JSONAPI.configuration.correlation_id_resolver
8
+ return nil unless resolver.respond_to?(:call)
9
+
10
+ resolver.call
11
+ rescue StandardError
12
+ nil
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ class HeaderWarningSubscriber
6
+ HEADER_EVENTS = %w[jpie.n1_detected jpie.slow_query_detected].freeze
7
+
8
+ def emit(event)
9
+ ev = event.is_a?(Hash) ? event : event.to_h
10
+ return unless HEADER_EVENTS.include?(ev[:name])
11
+
12
+ Thread.current[:jpie_warnings] ||= []
13
+ Thread.current[:jpie_warnings] << (ev[:name] == "jpie.n1_detected" ? "n1" : "slow_query")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ class N1LogSubscriber
6
+ def emit(event)
7
+ ev = event.is_a?(Hash) ? event : event.to_h
8
+ return unless ev[:name] == "jpie.n1_detected"
9
+
10
+ payload = ev[:payload] || {}
11
+ log_warn(build_message(payload), payload[:correlation_id])
12
+ end
13
+
14
+ private
15
+
16
+ def log_warn(msg, cid)
17
+ if cid.present?
18
+ Rails.logger.tagged("JPie", "CID(#{cid})") { Rails.logger.warn(msg) }
19
+ else
20
+ Rails.logger.warn("[JPie] #{msg}")
21
+ end
22
+ end
23
+
24
+ def build_message(payload)
25
+ msg = "N+1 detected #{payload[:method]} #{payload[:path]}"
26
+ if payload[:resource_type].present?
27
+ rt = payload[:resource_type]
28
+ rid = payload[:resource_id]
29
+ resource = rid.present? ? "#{rt}/#{rid}" : rt
30
+ msg += " resource=#{resource}"
31
+ end
32
+ msg += " include=#{payload[:include]}" if payload[:include].present?
33
+ msg += " query=#{payload[:n1_query]}" if payload[:n1_query].present?
34
+ msg
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ class ProsopiteInstrumentationLogger
6
+ def warn(progname = nil, &)
7
+ n1_details = block_given? ? yield : progname.to_s
8
+ return if n1_details.nil? || n1_details.empty?
9
+
10
+ env = Thread.current[:jpie_request_env]
11
+ payload = build_payload(env, n1_details)
12
+
13
+ return unless defined?(Rails) && Rails.respond_to?(:event)
14
+
15
+ Rails.event.tagged("jsonapi", "n1") do
16
+ Rails.event.notify("jpie.n1_detected", payload)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def build_payload(env, n1_details)
23
+ base = { n1_details: n1_details, n1_query: extract_first_query(n1_details) }
24
+ result = if env.nil?
25
+ base
26
+ else
27
+ request_payload(ActionDispatch::Request.new(env), n1_details).merge(n1_query: base[:n1_query])
28
+ end
29
+ result.merge(correlation_id: JSONAPI::Support::CorrelationId.resolve)
30
+ end
31
+
32
+ def extract_first_query(n1_details)
33
+ return nil if n1_details.nil? || n1_details.empty?
34
+
35
+ parts = n1_details.split("Call stack:")
36
+ return nil if parts.size < 2
37
+
38
+ query_section = parts[0].sub(/\AN\+1 queries detected:\n?/, "")
39
+ first_line = query_section.each_line.map(&:strip).reject(&:empty?).first
40
+ first_line.presence
41
+ end
42
+
43
+ def request_payload(req, n1_details)
44
+ params = req.params
45
+ {
46
+ path: req.path,
47
+ method: req.request_method,
48
+ include: params[:include],
49
+ resource_type: params[:resource_type],
50
+ resource_id: params[:id],
51
+ n1_details: n1_details,
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ class QueryTrackingLogSubscriber
6
+ QUERY_TRACKING_EVENTS = %w[jpie.slow_query_detected jpie.excessive_queries_detected].freeze
7
+
8
+ def emit(event)
9
+ ev = event.is_a?(Hash) ? event : event.to_h
10
+ name = ev[:name]
11
+ return unless QUERY_TRACKING_EVENTS.include?(name)
12
+
13
+ payload = ev[:payload] || {}
14
+ msg = if name == "jpie.slow_query_detected"
15
+ build_slow_query_message(payload)
16
+ else
17
+ build_excessive_queries_message(payload)
18
+ end
19
+ log_warn(msg, payload[:correlation_id])
20
+ end
21
+
22
+ private
23
+
24
+ def log_warn(msg, cid)
25
+ if cid.present?
26
+ Rails.logger.tagged("JPie", "CID(#{cid})") { Rails.logger.warn(msg) }
27
+ else
28
+ Rails.logger.warn("[JPie] #{msg}")
29
+ end
30
+ end
31
+
32
+ def build_slow_query_message(payload)
33
+ msg = "Slow query detected #{payload[:method]} #{payload[:path]}"
34
+ msg += " resource=#{resource_str(payload)}" if payload[:resource_type].present?
35
+ msg += " include=#{payload[:include]}" if payload[:include].present?
36
+ msg += " duration_ms=#{payload[:duration_ms]}" if payload[:duration_ms].present?
37
+ msg += " sql=#{payload[:sql]}" if payload[:sql].present?
38
+ msg
39
+ end
40
+
41
+ def build_excessive_queries_message(payload)
42
+ msg = "Excessive queries detected #{payload[:method]} #{payload[:path]}"
43
+ msg += " resource=#{resource_str(payload)}" if payload[:resource_type].present?
44
+ msg += " include=#{payload[:include]}" if payload[:include].present?
45
+ msg += " query_count=#{payload[:query_count]}" if payload[:query_count].present?
46
+ msg += " queries=#{payload[:queries].inspect}" if payload[:queries].present?
47
+ msg
48
+ end
49
+
50
+ def resource_str(payload)
51
+ rt = payload[:resource_type]
52
+ rid = payload[:resource_id]
53
+ rid.present? ? "#{rt}/#{rid}" : rt
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ class QueryTrackingSubscriber
6
+ SKIP_PATTERNS = [
7
+ /\A\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b/i,
8
+ /\A\s*(CREATE|ALTER|DROP)\s+(TABLE|INDEX|DATABASE)/i,
9
+ /\APRAGMA\b/i,
10
+ /sqlite_master|sqlite_temp_master/i,
11
+ /\bFROM\s+pg_/i,
12
+ /\A\s*(SET|SHOW)\s/i,
13
+ ].freeze
14
+
15
+ def call(name, start, finish, id, payload)
16
+ tracking = Thread.current[:jpie_query_tracking]
17
+ return if tracking.nil?
18
+
19
+ sql = payload[:sql]
20
+ return unless count_query?(sql)
21
+
22
+ tracking[:count] += 1
23
+ tracking[:queries] << sql
24
+
25
+ maybe_emit_slow_query(tracking, sql, name, start, finish, id, payload)
26
+ end
27
+
28
+ private
29
+
30
+ def count_query?(sql)
31
+ return false if sql.nil?
32
+
33
+ SKIP_PATTERNS.none? { |pattern| pattern.match?(sql) }
34
+ end
35
+
36
+ def maybe_emit_slow_query(tracking, sql, name, start, finish, id, payload) # rubocop:disable Metrics/ParameterLists
37
+ return unless defined?(Rails) && Rails.respond_to?(:event)
38
+
39
+ duration_ms = ActiveSupport::Notifications::Event.new(name, start, finish, id, payload).duration
40
+ threshold_ms = JSONAPI.configuration.slow_query_threshold_ms
41
+ return if duration_ms < threshold_ms
42
+
43
+ emit_slow_query_detected(tracking, sql, duration_ms, threshold_ms)
44
+ end
45
+
46
+ def emit_slow_query_detected(tracking, sql, duration_ms, threshold_ms)
47
+ event_payload = build_payload(tracking[:env]).merge(
48
+ sql: sql,
49
+ duration_ms: duration_ms,
50
+ threshold_ms: threshold_ms,
51
+ )
52
+
53
+ Rails.event.tagged("jsonapi", "slow_query") do
54
+ Rails.event.notify("jpie.slow_query_detected", event_payload)
55
+ end
56
+ end
57
+
58
+ def build_payload(env)
59
+ base = env.nil? ? {} : request_payload_from_env(env)
60
+ base.merge(correlation_id: JSONAPI::Support::CorrelationId.resolve)
61
+ end
62
+
63
+ def request_payload_from_env(env)
64
+ req = ActionDispatch::Request.new(env)
65
+ params = req.params
66
+ {
67
+ path: req.path,
68
+ method: req.request_method,
69
+ include: params[:include],
70
+ resource_type: params[:resource_type],
71
+ resource_id: params[:id],
72
+ }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JSONAPI
4
- VERSION = "2.0.0"
4
+ VERSION = "2.0.2"
5
5
  end
data/lib/json_api.rb CHANGED
@@ -29,14 +29,20 @@ require "json_api/support/sort_parsing"
29
29
  require "json_api/support/resource_identifier"
30
30
  require "json_api/support/relationship_helpers"
31
31
  require "json_api/support/param_helpers"
32
+ require "json_api/support/correlation_id"
32
33
  require "json_api/active_storage/detection"
33
34
  require "json_api/active_storage/serialization"
34
35
  require "json_api/active_storage/deserialization"
35
36
  require "json_api/support/active_storage_support"
36
37
  require "json_api/support/filter_parsing"
37
38
  require "json_api/support/collection_query"
38
- require "json_api/support/query_counter"
39
- require "json_api/rack/n1_warning"
39
+ require "json_api/support/prosopite_instrumentation_logger"
40
+ require "json_api/support/n1_log_subscriber"
41
+ require "json_api/support/header_warning_subscriber"
42
+ require "json_api/rack/n1_detection"
43
+ require "json_api/support/query_tracking_subscriber"
44
+ require "json_api/support/query_tracking_log_subscriber"
45
+ require "json_api/rack/query_tracking"
40
46
  require "json_api/routing"
41
47
  require "json_api/support/responders"
42
48
  require "json_api/support/instrumentation"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jpie
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emil Kampp
@@ -15,40 +15,68 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '8.1'
19
19
  - - ">="
20
20
  - !ruby/object:Gem::Version
21
- version: 8.0.0
21
+ version: 8.1.0
22
22
  type: :runtime
23
23
  prerelease: false
24
24
  version_requirements: !ruby/object:Gem::Requirement
25
25
  requirements:
26
26
  - - "~>"
27
27
  - !ruby/object:Gem::Version
28
- version: '8.0'
28
+ version: '8.1'
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
- version: 8.0.0
31
+ version: 8.1.0
32
+ - !ruby/object:Gem::Dependency
33
+ name: pg_query
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '4'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '4'
46
+ - !ruby/object:Gem::Dependency
47
+ name: prosopite
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '1'
32
60
  - !ruby/object:Gem::Dependency
33
61
  name: rails
34
62
  requirement: !ruby/object:Gem::Requirement
35
63
  requirements:
36
64
  - - "~>"
37
65
  - !ruby/object:Gem::Version
38
- version: '8.0'
66
+ version: '8.1'
39
67
  - - ">="
40
68
  - !ruby/object:Gem::Version
41
- version: 8.0.0
69
+ version: 8.1.0
42
70
  type: :runtime
43
71
  prerelease: false
44
72
  version_requirements: !ruby/object:Gem::Requirement
45
73
  requirements:
46
74
  - - "~>"
47
75
  - !ruby/object:Gem::Version
48
- version: '8.0'
76
+ version: '8.1'
49
77
  - - ">="
50
78
  - !ruby/object:Gem::Version
51
- version: 8.0.0
79
+ version: 8.1.0
52
80
  description: A Rails 8+ gem that provides jsonapi_resources routing DSL and generic
53
81
  JSON:API controllers
54
82
  email:
@@ -94,6 +122,8 @@ files:
94
122
  - lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb
95
123
  - lib/json_api/controllers/concerns/resource_actions/field_validation.rb
96
124
  - lib/json_api/controllers/concerns/resource_actions/filter_validation.rb
125
+ - lib/json_api/controllers/concerns/resource_actions/include_preloading.rb
126
+ - lib/json_api/controllers/concerns/resource_actions/include_validation.rb
97
127
  - lib/json_api/controllers/concerns/resource_actions/pagination.rb
98
128
  - lib/json_api/controllers/concerns/resource_actions/resource_loading.rb
99
129
  - lib/json_api/controllers/concerns/resource_actions/serialization.rb
@@ -101,7 +131,8 @@ files:
101
131
  - lib/json_api/controllers/relationships_controller.rb
102
132
  - lib/json_api/controllers/resources_controller.rb
103
133
  - lib/json_api/errors/parameter_not_allowed.rb
104
- - lib/json_api/rack/n1_warning.rb
134
+ - lib/json_api/rack/n1_detection.rb
135
+ - lib/json_api/rack/query_tracking.rb
105
136
  - lib/json_api/railtie.rb
106
137
  - lib/json_api/resources/active_storage_blob_resource.rb
107
138
  - lib/json_api/resources/concerns/attributes_dsl.rb
@@ -135,10 +166,15 @@ files:
135
166
  - lib/json_api/support/concerns/polymorphic_filters.rb
136
167
  - lib/json_api/support/concerns/regular_filters.rb
137
168
  - lib/json_api/support/concerns/sorting.rb
169
+ - lib/json_api/support/correlation_id.rb
138
170
  - lib/json_api/support/filter_parsing.rb
171
+ - lib/json_api/support/header_warning_subscriber.rb
139
172
  - lib/json_api/support/instrumentation.rb
173
+ - lib/json_api/support/n1_log_subscriber.rb
140
174
  - lib/json_api/support/param_helpers.rb
141
- - lib/json_api/support/query_counter.rb
175
+ - lib/json_api/support/prosopite_instrumentation_logger.rb
176
+ - lib/json_api/support/query_tracking_log_subscriber.rb
177
+ - lib/json_api/support/query_tracking_subscriber.rb
142
178
  - lib/json_api/support/relationship_guard.rb
143
179
  - lib/json_api/support/relationship_helpers.rb
144
180
  - lib/json_api/support/resource_identifier.rb
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module Rack
5
- class N1Warning
6
- def initialize(app)
7
- @app = app
8
- end
9
-
10
- def call(env)
11
- return @app.call(env) unless JSONAPI.configuration.n1_warning_enabled
12
- return @app.call(env) unless jsonapi_request?(env)
13
-
14
- result = nil
15
- query_count = JSONAPI::QueryCounter.count_queries { result = @app.call(env) }
16
- status, headers, body = result
17
- headers = add_n1_headers(headers, query_count, env)
18
- [status, headers, body]
19
- end
20
-
21
- private
22
-
23
- def add_n1_headers(headers, query_count, env)
24
- headers = headers.dup
25
- headers["X-JPie-Query-Count"] = query_count.to_s
26
- headers["Server-Timing"] = append_server_timing(headers["Server-Timing"].to_s, query_count)
27
- add_perf_warning_if_needed!(headers, query_count, env)
28
- headers
29
- end
30
-
31
- def add_perf_warning_if_needed!(headers, query_count, env)
32
- threshold = JSONAPI.configuration.n1_query_threshold
33
- return unless query_count > threshold
34
-
35
- warning_msg = n1_warning_message(query_count, threshold)
36
- headers["X-JPie-Performance-Warning"] = warning_msg
37
- headers["Server-Timing"] = [headers["Server-Timing"], perf_warning_metric(warning_msg)].join(", ")
38
- log_n1_warning(env, query_count, threshold)
39
- end
40
-
41
- def append_server_timing(existing, query_count)
42
- db_metric = "db;desc=\"#{query_count} queries\""
43
- [existing, db_metric].reject(&:empty?).join(", ")
44
- end
45
-
46
- def n1_warning_message(query_count, threshold)
47
- "N+1 possible (#{query_count} queries, threshold #{threshold}) - " \
48
- "add include param for nested relationships"
49
- end
50
-
51
- def perf_warning_metric(warning_msg)
52
- safe_desc = warning_msg.tr('"', "'")
53
- "perf-warning;desc=\"#{safe_desc}\""
54
- end
55
-
56
- def jsonapi_request?(env)
57
- env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
58
- end
59
-
60
- def log_n1_warning(env, count, threshold)
61
- return unless defined?(Rails) && Rails.logger
62
-
63
- req = ActionDispatch::Request.new(env)
64
- Rails.logger.warn(
65
- "[JPie] N+1 query warning: #{count} queries (threshold #{threshold}) " \
66
- "path=#{req.path} include=#{req.params[:include].inspect} " \
67
- "resource=#{req.params[:resource_type]}",
68
- )
69
- end
70
- end
71
- end
72
- end
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module QueryCounter
5
- SKIP_PATTERN = /\A(\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b)/i
6
- SCHEMA_PATTERN = /\A(\s*(CREATE|ALTER|DROP)\s+(TABLE|INDEX|DATABASE))/i
7
- PRAGMA_PATTERN = /\APRAGMA\b/i
8
- SQLITE_MASTER_PATTERN = /sqlite_master|sqlite_temp_master/i
9
-
10
- def self.count(&)
11
- queries = []
12
- counter = ->(_name, _start, _finish, _id, payload) { count_sql(queries, payload) }
13
- ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &)
14
- queries
15
- end
16
-
17
- def self.count_queries(&)
18
- count(&).size
19
- end
20
-
21
- def self.count_sql(queries, payload)
22
- sql = payload[:sql]
23
- return if sql.nil?
24
- return if SKIP_PATTERN.match?(sql)
25
- return if SCHEMA_PATTERN.match?(sql)
26
- return if PRAGMA_PATTERN.match?(sql)
27
- return if SQLITE_MASTER_PATTERN.match?(sql)
28
-
29
- queries << sql
30
- end
31
- end
32
- end