upkeep-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of upkeep-rails might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +424 -0
- data/docs/architecture/ambient-inputs-roadmap.md +306 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +187 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/cost-model-roadmap.md +703 -0
- data/docs/guides/getting-started.md +282 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/testing.md +113 -0
- data/lib/generators/upkeep/install/install_generator.rb +90 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
- data/lib/generators/upkeep/install/templates/subscription.js +107 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +275 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +466 -0
- data/lib/upkeep/herb/developer_report.rb +116 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +84 -0
- data/lib/upkeep/herb/template_manifest.rb +377 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +341 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +765 -0
- data/lib/upkeep/rails/cable/channel.rb +108 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +37 -0
- data/lib/upkeep/rails/configuration.rb +57 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +36 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +36 -0
- data/lib/upkeep/rails.rb +276 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1075 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
- data/lib/upkeep/subscriptions/active_registry.rb +93 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +159 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +53 -0
- metadata +296 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_cable"
|
|
4
|
+
require "active_support/notifications"
|
|
5
|
+
|
|
6
|
+
module Upkeep
|
|
7
|
+
module Rails
|
|
8
|
+
module Cable
|
|
9
|
+
class Channel < ::ActionCable::Channel::Base
|
|
10
|
+
SUBSCRIBE_NOTIFICATION = "subscribe_channel.upkeep"
|
|
11
|
+
|
|
12
|
+
def subscribed
|
|
13
|
+
if ActiveSupport::Notifications.notifier.listening?(SUBSCRIBE_NOTIFICATION)
|
|
14
|
+
instrumented_subscribe
|
|
15
|
+
else
|
|
16
|
+
subscribe_without_instrumentation
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def unsubscribed
|
|
21
|
+
Upkeep::Rails.subscriptions.unregister(subscription_id)
|
|
22
|
+
rescue KeyError, ActiveRecord::RecordNotFound
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def instrumented_subscribe
|
|
29
|
+
payload = { subscription_id: safe_subscription_id }
|
|
30
|
+
ActiveSupport::Notifications.instrument(SUBSCRIBE_NOTIFICATION, payload) do
|
|
31
|
+
subscribe_without_instrumentation(payload: payload)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def subscribe_without_instrumentation(payload: nil)
|
|
36
|
+
id = subscription_id
|
|
37
|
+
payload[:subscription_id] = id if payload
|
|
38
|
+
subscription = measure(payload, :fetch_ms) { Upkeep::Rails.subscriptions.fetch(id) }
|
|
39
|
+
authorized = measure(payload, :authorization_ms) { authorized_subscription?(subscription) }
|
|
40
|
+
unless authorized
|
|
41
|
+
payload[:rejected] = true if payload
|
|
42
|
+
return reject
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
measure(payload, :activation_ms) { Upkeep::Rails.subscriptions.activate(id) }
|
|
46
|
+
stream_count = measure(payload, :stream_attach_ms) { attach_streams(subscription) }
|
|
47
|
+
payload[:stream_count] = stream_count if payload
|
|
48
|
+
rescue KeyError, ActiveRecord::RecordNotFound, UnidentifiedSubscriber
|
|
49
|
+
payload[:rejected] = true if payload
|
|
50
|
+
reject
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def attach_streams(subscription)
|
|
54
|
+
stream_from stream_name_for(subscription)
|
|
55
|
+
count = 1
|
|
56
|
+
shared_stream_names_for(subscription).each do |stream_name|
|
|
57
|
+
stream_from stream_name
|
|
58
|
+
count += 1
|
|
59
|
+
end
|
|
60
|
+
count
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def measure(payload, key)
|
|
64
|
+
return yield unless payload
|
|
65
|
+
|
|
66
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
yield
|
|
68
|
+
ensure
|
|
69
|
+
payload[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3) if payload && started_at
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def safe_subscription_id
|
|
73
|
+
subscription_id
|
|
74
|
+
rescue KeyError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def subscription_id
|
|
79
|
+
params.fetch(:subscription_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def authorized_subscription?(subscription)
|
|
83
|
+
return true if anonymous_public_subscription?(subscription)
|
|
84
|
+
return true unless metadata_value(subscription, :identity_mode)
|
|
85
|
+
|
|
86
|
+
SubscriberIdentity.derive_all(connection)
|
|
87
|
+
.any? { |identity| identity.subscriber_id == subscription.subscriber_id }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def anonymous_public_subscription?(subscription)
|
|
91
|
+
metadata_value(subscription, :identity_mode) == SubscriberIdentity::ANONYMOUS_PUBLIC_MODE
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stream_name_for(subscription)
|
|
95
|
+
metadata_value(subscription, :stream_name) || subscription.metadata.fetch(:stream_name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def shared_stream_names_for(subscription)
|
|
99
|
+
metadata_value(subscription, :shared_stream_names) || []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def metadata_value(subscription, key)
|
|
103
|
+
subscription.metadata[key] || subscription.metadata[key.to_s]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Upkeep
|
|
8
|
+
module Rails
|
|
9
|
+
module Cable
|
|
10
|
+
class UnidentifiedSubscriber < StandardError; end
|
|
11
|
+
|
|
12
|
+
Identity = Data.define(:subscriber_id, :stream_name, :components)
|
|
13
|
+
Decision = Data.define(:mode, :anonymous, :deopt_reason, :identity_sources)
|
|
14
|
+
|
|
15
|
+
module SubscriberIdentity
|
|
16
|
+
ANONYMOUS_PUBLIC_MODE = "anonymous_public"
|
|
17
|
+
IDENTIFIED_MODE = "identified"
|
|
18
|
+
CONNECTION_IDENTITY_SOURCES = %w[Current.user cookie current_attribute session warden_user].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def derive(connection)
|
|
23
|
+
derive_all(connection).last
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def derive_all(connection)
|
|
27
|
+
identities = []
|
|
28
|
+
request_components = request_components(connection.request) if connection.respond_to?(:request)
|
|
29
|
+
identifier_components = identifier_components(connection)
|
|
30
|
+
|
|
31
|
+
identities << for_components(request_components) if request_components&.any?
|
|
32
|
+
identities << for_components(Array(request_components) + identifier_components) if identifier_components.any?
|
|
33
|
+
identities = identities.uniq(&:subscriber_id)
|
|
34
|
+
|
|
35
|
+
raise UnidentifiedSubscriber, "ActionCable connection has no server identifiers" if identities.empty?
|
|
36
|
+
|
|
37
|
+
identities
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def derive_from_request(request, recorder:, decision: decision_for(request, recorder: recorder))
|
|
41
|
+
components = if decision.anonymous
|
|
42
|
+
anonymous_components
|
|
43
|
+
else
|
|
44
|
+
request_components(request) + recorder_components(recorder)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if components.empty?
|
|
48
|
+
raise UnidentifiedSubscriber,
|
|
49
|
+
"subscription has identity dependencies but no canonical request or recorder identity"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
for_components(components)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def decision_for(_request = nil, recorder:)
|
|
56
|
+
dependencies = identity_dependencies(recorder)
|
|
57
|
+
if dependencies.empty?
|
|
58
|
+
Decision.new(ANONYMOUS_PUBLIC_MODE, true, nil, [])
|
|
59
|
+
else
|
|
60
|
+
Decision.new(
|
|
61
|
+
IDENTIFIED_MODE,
|
|
62
|
+
false,
|
|
63
|
+
"identity_dependencies_present",
|
|
64
|
+
dependencies.map { |dependency| dependency.source.to_s }.uniq.sort
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def identifier_components(connection)
|
|
70
|
+
identifiers = Array(connection.identifiers)
|
|
71
|
+
identifiers.map { |name| component_for(name, connection.public_send(name)) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def request_components(request)
|
|
75
|
+
session_id = session_id_for(request)
|
|
76
|
+
return [] unless session_id
|
|
77
|
+
|
|
78
|
+
[scalar_component(:rails_session, session_id)]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def for_identifiers(identifiers)
|
|
82
|
+
for_components(identifiers.map { |name, value| component_for(name, value) })
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def for_components(components)
|
|
86
|
+
canonical_bytes = JSON.generate(components.sort_by { |component| component.fetch(:name) })
|
|
87
|
+
subscriber_id = "action_cable:#{Digest::SHA256.hexdigest(canonical_bytes)}"
|
|
88
|
+
|
|
89
|
+
Identity.new(
|
|
90
|
+
subscriber_id,
|
|
91
|
+
Delivery::ActionCableAdapter.stream_name_for(subscriber_id),
|
|
92
|
+
components
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def recorder_components(recorder)
|
|
97
|
+
identity_dependencies(recorder)
|
|
98
|
+
.filter_map { |dependency| component_for_dependency(dependency) }
|
|
99
|
+
.uniq
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def anonymous_components
|
|
103
|
+
[ scalar_component(:anonymous_public_subscription, SecureRandom.uuid) ]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def identity_dependencies(recorder)
|
|
107
|
+
return [] unless recorder
|
|
108
|
+
|
|
109
|
+
recorder.graph.dependency_nodes
|
|
110
|
+
.map(&:payload)
|
|
111
|
+
.select(&:identity?)
|
|
112
|
+
.select { |dependency| connection_identity_dependency?(dependency) }
|
|
113
|
+
.uniq(&:cache_key)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def connection_identity_dependency?(dependency)
|
|
117
|
+
CONNECTION_IDENTITY_SOURCES.include?(dependency.source.to_s)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def component_for_dependency(dependency)
|
|
121
|
+
if dependency.source == :current_attribute && current_user_dependency?(dependency)
|
|
122
|
+
model_component(:current_user, dependency.key.fetch(:value))
|
|
123
|
+
elsif dependency.source == "Current.user"
|
|
124
|
+
model_component(:current_user, dependency.metadata)
|
|
125
|
+
elsif dependency.source == :warden_user
|
|
126
|
+
model_component(:"warden_#{dependency.metadata.fetch(:scope)}", dependency.key.fetch(:value))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def current_user_dependency?(dependency)
|
|
131
|
+
dependency.metadata.fetch(:name) == "user"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def model_component(name, identity)
|
|
135
|
+
return unless identity.is_a?(Hash) && identity[:model] && identity[:id]
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
name: name.to_s,
|
|
139
|
+
kind: "model",
|
|
140
|
+
model: identity.fetch(:model),
|
|
141
|
+
id: identity.fetch(:id).to_s
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def component_for(name, value)
|
|
146
|
+
raise UnidentifiedSubscriber, "ActionCable identifier #{name} is nil" if value.nil?
|
|
147
|
+
|
|
148
|
+
if active_record?(value)
|
|
149
|
+
active_record_component(name, value)
|
|
150
|
+
elsif scalar?(value)
|
|
151
|
+
scalar_component(name, value)
|
|
152
|
+
elsif value.respond_to?(:to_gid_param)
|
|
153
|
+
global_id_component(name, value)
|
|
154
|
+
else
|
|
155
|
+
raise UnidentifiedSubscriber, "ActionCable identifier #{name} has no canonical identity"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def active_record?(value)
|
|
160
|
+
defined?(::ActiveRecord::Base) && value.is_a?(::ActiveRecord::Base)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def active_record_component(name, record)
|
|
164
|
+
raise UnidentifiedSubscriber, "ActionCable identifier #{name} is an unsaved record" unless record.id
|
|
165
|
+
|
|
166
|
+
model_component(name, model: record.class.name, id: record.id)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def scalar?(value)
|
|
170
|
+
value.is_a?(String) ||
|
|
171
|
+
value.is_a?(Symbol) ||
|
|
172
|
+
value.is_a?(Integer) ||
|
|
173
|
+
value == true ||
|
|
174
|
+
value == false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def scalar_component(name, value)
|
|
178
|
+
{
|
|
179
|
+
name: name.to_s,
|
|
180
|
+
kind: "scalar",
|
|
181
|
+
class: value.class.name,
|
|
182
|
+
value: value.to_s
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def global_id_component(name, value)
|
|
187
|
+
{
|
|
188
|
+
name: name.to_s,
|
|
189
|
+
kind: "global_id",
|
|
190
|
+
value: value.to_gid_param
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def session_id_for(request)
|
|
195
|
+
return unless request&.respond_to?(:session)
|
|
196
|
+
|
|
197
|
+
session = request.session
|
|
198
|
+
session_id = session.id if session.respond_to?(:id)
|
|
199
|
+
session_id = session_id.public_id if session_id.respond_to?(:public_id)
|
|
200
|
+
session_id = session_id.private_id if session_id.respond_to?(:private_id)
|
|
201
|
+
session_id = session[:session_id] if blank?(session_id) && session.respond_to?(:[])
|
|
202
|
+
|
|
203
|
+
session_id.to_s unless blank?(session_id)
|
|
204
|
+
rescue StandardError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def blank?(value)
|
|
209
|
+
value.nil? || value == ""
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module Rails
|
|
7
|
+
module ClientSubscription
|
|
8
|
+
CHANNEL = "Upkeep::Rails::Cable::Channel"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def inject(html, identity:, subscription:)
|
|
13
|
+
marker = marker_for(identity: identity, subscription: subscription)
|
|
14
|
+
insert_before_closing("head", html, marker) ||
|
|
15
|
+
insert_before_closing("body", html, marker) ||
|
|
16
|
+
"#{html}#{marker}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def marker_for(identity:, subscription:)
|
|
20
|
+
payload = JSON.generate(
|
|
21
|
+
channel: CHANNEL,
|
|
22
|
+
subscription_id: subscription.id,
|
|
23
|
+
stream_name: identity.stream_name
|
|
24
|
+
).gsub("</", '<\/')
|
|
25
|
+
|
|
26
|
+
%(<script type="application/json" data-upkeep-subscription>#{payload}</script>)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def insert_before_closing(tag, html, marker)
|
|
30
|
+
index = html.rindex(%(</#{tag}>)) || html.rindex(%(</#{tag.upcase}>))
|
|
31
|
+
return unless index
|
|
32
|
+
|
|
33
|
+
"#{html[0...index]}#{marker}#{html[index..]}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Rails
|
|
5
|
+
class ConfigurationError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class Configuration
|
|
8
|
+
SUBSCRIPTION_STORES = [:active_record, :memory].freeze
|
|
9
|
+
REFUSED_BOUNDARY_BEHAVIORS = [:raise, :warn].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor :enabled
|
|
12
|
+
attr_reader :subscription_store
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@enabled = true
|
|
16
|
+
@subscription_store = :active_record
|
|
17
|
+
@refused_boundary_behavior = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def subscription_store=(value)
|
|
21
|
+
value = value.to_sym if value.respond_to?(:to_sym)
|
|
22
|
+
|
|
23
|
+
unless SUBSCRIPTION_STORES.include?(value)
|
|
24
|
+
raise ConfigurationError,
|
|
25
|
+
"Unknown Upkeep subscription_store #{value.inspect}; expected one of #{SUBSCRIPTION_STORES.join(", ")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@subscription_store = value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def refused_boundary_behavior
|
|
32
|
+
@refused_boundary_behavior || default_refused_boundary_behavior
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def refused_boundary_behavior=(value)
|
|
36
|
+
value = value.to_sym if value.respond_to?(:to_sym)
|
|
37
|
+
|
|
38
|
+
unless REFUSED_BOUNDARY_BEHAVIORS.include?(value)
|
|
39
|
+
raise ConfigurationError,
|
|
40
|
+
"Unknown Upkeep refused_boundary_behavior #{value.inspect}; expected one of #{REFUSED_BOUNDARY_BEHAVIORS.join(", ")}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@refused_boundary_behavior = value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def default_refused_boundary_behavior
|
|
49
|
+
if defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.to_s == "production"
|
|
50
|
+
:warn
|
|
51
|
+
else
|
|
52
|
+
:raise
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module Rails
|
|
7
|
+
module ControllerRuntime
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
SUPPRESS_KEY = :upkeep_rails_controller_runtime_suppressed
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
prepend_around_action :upkeep_capture_request
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def install
|
|
19
|
+
return if @installed
|
|
20
|
+
return unless defined?(::ActionController::Base)
|
|
21
|
+
|
|
22
|
+
::ActionController::Base.include(self)
|
|
23
|
+
@installed = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def installed?
|
|
27
|
+
!!@installed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset!
|
|
31
|
+
@installed = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def suppress
|
|
35
|
+
previous = Thread.current[SUPPRESS_KEY]
|
|
36
|
+
Thread.current[SUPPRESS_KEY] = true
|
|
37
|
+
yield
|
|
38
|
+
ensure
|
|
39
|
+
Thread.current[SUPPRESS_KEY] = previous
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def suppressed?
|
|
43
|
+
Thread.current[SUPPRESS_KEY]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def upkeep_capture_request(&action)
|
|
49
|
+
return action.call if ControllerRuntime.suppressed?
|
|
50
|
+
return action.call if Upkeep::Runtime::Observation.recorder
|
|
51
|
+
|
|
52
|
+
payload = {
|
|
53
|
+
controller: self.class.name,
|
|
54
|
+
action: action_name,
|
|
55
|
+
method: request.request_method,
|
|
56
|
+
path: request.fullpath,
|
|
57
|
+
subscription_request: upkeep_subscription_request?
|
|
58
|
+
}
|
|
59
|
+
ActiveSupport::Notifications.instrument(Upkeep::Rails::REQUEST_CAPTURE, payload) do
|
|
60
|
+
upkeep_capture_request_with_timing(action, payload)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def upkeep_capture_request_with_timing(action, payload)
|
|
65
|
+
measure_phase(payload, :deliver_pending_ms) { Upkeep::Rails.deliver_changes_now! }
|
|
66
|
+
|
|
67
|
+
result = nil
|
|
68
|
+
capture = nil
|
|
69
|
+
changes = []
|
|
70
|
+
measure_phase(payload, :change_capture_ms) do
|
|
71
|
+
_captured, changes = Upkeep::Runtime::ChangeLog.capture do
|
|
72
|
+
if payload.fetch(:subscription_request)
|
|
73
|
+
capture = Upkeep::Capture::Request.call(self, profile: request_capture_profile?) { action.call }
|
|
74
|
+
result = capture.action_result
|
|
75
|
+
else
|
|
76
|
+
measure_phase(payload, :action_ms) { result = action.call }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
record_capture_payload(payload, capture) if capture
|
|
81
|
+
|
|
82
|
+
registration = nil
|
|
83
|
+
if capture
|
|
84
|
+
measure_phase(payload, :register_ms) do
|
|
85
|
+
registration = Upkeep::Rails.register_controller_subscription(self, capture)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
payload[:registered] = !!registration
|
|
89
|
+
if capture && registration
|
|
90
|
+
measure_phase(payload, :inject_ms) do
|
|
91
|
+
response.body = Upkeep::Rails::ClientSubscription.inject(
|
|
92
|
+
capture.html,
|
|
93
|
+
identity: registration.identity,
|
|
94
|
+
subscription: registration.subscription
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
payload[:subscription_id] = registration.subscription.id
|
|
98
|
+
end
|
|
99
|
+
measure_phase(payload, :deliver_changes_ms) { Upkeep::Rails.deliver_changes!(changes) }
|
|
100
|
+
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def record_capture_payload(payload, capture)
|
|
105
|
+
payload[:response_status] = capture.response_status
|
|
106
|
+
payload[:response_content_type] = capture.response_content_type
|
|
107
|
+
payload[:response_media_type] = capture.response_media_type
|
|
108
|
+
payload[:html_response] = capture.html_response?
|
|
109
|
+
payload[:response_successful] = capture.successful?
|
|
110
|
+
payload[:html_bytes] = capture.html.bytesize
|
|
111
|
+
payload[:graph_frames] = capture.recorder.graph.frame_nodes.size
|
|
112
|
+
payload[:graph_dependencies] = capture.recorder.graph.dependency_nodes.size
|
|
113
|
+
capture.timings.each do |phase, ms|
|
|
114
|
+
payload[:"capture_#{phase}"] = ms
|
|
115
|
+
end
|
|
116
|
+
capture.counters.each do |counter, value|
|
|
117
|
+
payload[:"capture_#{counter}"] = value
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def measure_phase(payload, key)
|
|
122
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
123
|
+
yield
|
|
124
|
+
ensure
|
|
125
|
+
payload[key] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def upkeep_subscription_request?
|
|
129
|
+
request.get? || request.head?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def request_capture_profile?
|
|
133
|
+
ActiveSupport::Notifications.notifier.listening?(Upkeep::Rails::REQUEST_CAPTURE)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Rails
|
|
5
|
+
module Install
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
return unless Upkeep::Rails.configuration.enabled
|
|
10
|
+
return if @installed
|
|
11
|
+
|
|
12
|
+
Runtime::Install.call if defined?(::ActiveRecord::Base)
|
|
13
|
+
ActionViewCapture.install if defined?(::ActionView::Template)
|
|
14
|
+
ControllerRuntime.install if defined?(::ActionController::Base)
|
|
15
|
+
|
|
16
|
+
@installed = true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def installed?
|
|
20
|
+
!!@installed
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset!
|
|
24
|
+
@installed = false
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Upkeep
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
config.upkeep = ActiveSupport::OrderedOptions.new
|
|
7
|
+
|
|
8
|
+
initializer "upkeep_rails.configure" do |app|
|
|
9
|
+
Upkeep::Rails.configure do |config|
|
|
10
|
+
config.enabled = app.config.upkeep.fetch(:enabled, true)
|
|
11
|
+
config.subscription_store = app.config.upkeep.fetch(:subscription_store, config.subscription_store)
|
|
12
|
+
config.refused_boundary_behavior =
|
|
13
|
+
app.config.upkeep.fetch(:refused_boundary_behavior, config.refused_boundary_behavior)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "upkeep_rails.install" do
|
|
18
|
+
ActiveSupport.on_load(:active_record) { Upkeep::Rails::Install.call }
|
|
19
|
+
ActiveSupport.on_load(:action_controller_base) { Upkeep::Rails::Install.call }
|
|
20
|
+
ActiveSupport.on_load(:action_view) { Upkeep::Rails::Install.call }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
initializer "upkeep_rails.validate_configuration", after: "upkeep_rails.install" do |app|
|
|
24
|
+
app.config.after_initialize do
|
|
25
|
+
Upkeep::Rails.validate_configuration! unless Railtie.rake_task?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.rake_task?
|
|
30
|
+
defined?(::Rake) &&
|
|
31
|
+
::Rake.respond_to?(:application) &&
|
|
32
|
+
::Rake.application.top_level_tasks.any?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|