jpie 2.0.0 → 2.0.1
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 +4 -4
- data/.rubocop.yml +1 -0
- data/Gemfile.lock +29 -3
- data/README.md +138 -12
- data/jpie.gemspec +4 -2
- data/lib/json_api/configuration.rb +18 -5
- data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +0 -39
- data/lib/json_api/controllers/concerns/resource_actions/include_preloading.rb +89 -0
- data/lib/json_api/controllers/concerns/resource_actions/include_validation.rb +50 -0
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +3 -37
- data/lib/json_api/controllers/concerns/resource_actions.rb +3 -0
- data/lib/json_api/rack/n1_detection.rb +41 -0
- data/lib/json_api/rack/query_tracking.rb +102 -0
- data/lib/json_api/railtie.rb +29 -3
- data/lib/json_api/support/correlation_id.rb +16 -0
- data/lib/json_api/support/header_warning_subscriber.rb +17 -0
- data/lib/json_api/support/n1_log_subscriber.rb +38 -0
- data/lib/json_api/support/prosopite_instrumentation_logger.rb +56 -0
- data/lib/json_api/support/query_tracking_log_subscriber.rb +57 -0
- data/lib/json_api/support/query_tracking_subscriber.rb +76 -0
- data/lib/json_api/version.rb +1 -1
- data/lib/json_api.rb +8 -2
- metadata +47 -11
- data/lib/json_api/rack/n1_warning.rb +0 -72
- data/lib/json_api/support/query_counter.rb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48369caaf5706cefe367cf5450398a4620741a09193d1ad37e4b42738eb33cc3
|
|
4
|
+
data.tar.gz: 767a5a640a1e0ba22902d7d6d850bcf1c1491ec3903c041845a09d5db9eb60fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 73f3dd657b1efa1eda908b32fe45e189ce355a159e8d48b805ebc1b2cf7afdbca2633e31fe93e56d9cf298956561f993db87c9acbe9cf13c12f8d72f8f82e717
|
|
7
|
+
data.tar.gz: d2f3c7e883efe3a27cadf78287903c26ae6fde6c411f84b553e6fe85fd0e4433eac589dfa42ce43886dccb5ead4828205e1b34139dc5fc0d18792adc8dcc4ca4
|
data/.rubocop.yml
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
jpie (2.0.
|
|
5
|
-
actionpack (~> 8.
|
|
6
|
-
|
|
4
|
+
jpie (2.0.1)
|
|
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.
|
|
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
|
|
662
|
+
### N+1 Detection (Rails 8.1+)
|
|
663
663
|
|
|
664
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
28
|
-
spec.add_dependency "
|
|
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,11 @@ 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
|
-
:
|
|
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
|
|
9
13
|
|
|
10
14
|
def initialize
|
|
11
15
|
set_defaults
|
|
@@ -46,7 +50,8 @@ module JSONAPI
|
|
|
46
50
|
|
|
47
51
|
def set_defaults
|
|
48
52
|
set_pagination_defaults
|
|
49
|
-
|
|
53
|
+
set_n1_detection_defaults
|
|
54
|
+
set_query_tracking_defaults
|
|
50
55
|
set_nil_defaults
|
|
51
56
|
@document_meta_resolver = ->(controller:) { {} } # rubocop:disable Lint/UnusedBlockArgument
|
|
52
57
|
@base_controller_class = "ActionController::API"
|
|
@@ -58,15 +63,23 @@ module JSONAPI
|
|
|
58
63
|
@max_page_size = 100
|
|
59
64
|
end
|
|
60
65
|
|
|
61
|
-
def
|
|
62
|
-
@
|
|
63
|
-
|
|
66
|
+
def set_n1_detection_defaults
|
|
67
|
+
@n1_detection_enabled = !defined?(Rails) || !Rails.env.production?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def set_query_tracking_defaults
|
|
71
|
+
@query_tracking_enabled = !defined?(Rails) || !Rails.env.production?
|
|
72
|
+
@query_count_threshold = 10
|
|
73
|
+
@slow_query_threshold_ms = 200
|
|
74
|
+
@query_tracking_queries_cap = 100
|
|
75
|
+
@jpie_headers_enabled = !defined?(Rails) || !Rails.env.production?
|
|
64
76
|
end
|
|
65
77
|
|
|
66
78
|
def set_nil_defaults
|
|
67
79
|
@jsonapi_meta = nil
|
|
68
80
|
@authorization_handler = nil
|
|
69
81
|
@authorization_scope = nil
|
|
82
|
+
@correlation_id_resolver = nil
|
|
70
83
|
end
|
|
71
84
|
|
|
72
85
|
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
|
data/lib/json_api/railtie.rb
CHANGED
|
@@ -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.
|
|
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::
|
|
27
|
+
app.config.middleware.insert_after ActionDispatch::ServerTiming, JSONAPI::Rack::N1Detection
|
|
22
28
|
else
|
|
23
|
-
app.config.middleware.use JSONAPI::Rack::
|
|
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
|
|
@@ -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
|
data/lib/json_api/version.rb
CHANGED
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/
|
|
39
|
-
require "json_api/
|
|
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.
|
|
4
|
+
version: 2.0.1
|
|
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.
|
|
18
|
+
version: '8.1'
|
|
19
19
|
- - ">="
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
|
-
version: 8.
|
|
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.
|
|
28
|
+
version: '8.1'
|
|
29
29
|
- - ">="
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
|
-
version: 8.
|
|
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.
|
|
66
|
+
version: '8.1'
|
|
39
67
|
- - ">="
|
|
40
68
|
- !ruby/object:Gem::Version
|
|
41
|
-
version: 8.
|
|
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.
|
|
76
|
+
version: '8.1'
|
|
49
77
|
- - ">="
|
|
50
78
|
- !ruby/object:Gem::Version
|
|
51
|
-
version: 8.
|
|
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/
|
|
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/
|
|
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
|