julewire-rails 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +94 -0
  5. data/docs/advanced-configuration.md +17 -0
  6. data/docs/capture-and-filtering.md +102 -0
  7. data/docs/configuration.md +83 -0
  8. data/docs/development.md +21 -0
  9. data/docs/events-and-errors.md +46 -0
  10. data/docs/lifecycle.md +24 -0
  11. data/docs/request-logging.md +49 -0
  12. data/julewire-rails.gemspec +44 -0
  13. data/lib/generators/julewire/install_generator.rb +15 -0
  14. data/lib/generators/julewire/templates/julewire.rb +16 -0
  15. data/lib/julewire/rails/configuration.rb +74 -0
  16. data/lib/julewire/rails/context_body_proxy.rb +54 -0
  17. data/lib/julewire/rails/debug_exception_log_silencer.rb +53 -0
  18. data/lib/julewire/rails/doctor_app.rb +233 -0
  19. data/lib/julewire/rails/exception_severity.rb +27 -0
  20. data/lib/julewire/rails/lifecycle_hooks.rb +76 -0
  21. data/lib/julewire/rails/log_subscriber_silencer.rb +52 -0
  22. data/lib/julewire/rails/logger.rb +185 -0
  23. data/lib/julewire/rails/logger_outputs.rb +36 -0
  24. data/lib/julewire/rails/output_requirement.rb +38 -0
  25. data/lib/julewire/rails/parameter_filter_plan.rb +100 -0
  26. data/lib/julewire/rails/parameter_filter_processor.rb +117 -0
  27. data/lib/julewire/rails/railtie.rb +84 -0
  28. data/lib/julewire/rails/request_attributes.rb +126 -0
  29. data/lib/julewire/rails/request_completion.rb +120 -0
  30. data/lib/julewire/rails/request_context.rb +91 -0
  31. data/lib/julewire/rails/request_error_ownership.rb +63 -0
  32. data/lib/julewire/rails/request_fields.rb +61 -0
  33. data/lib/julewire/rails/request_lifecycle.rb +109 -0
  34. data/lib/julewire/rails/request_middleware.rb +130 -0
  35. data/lib/julewire/rails/request_summary_timeout_scheduler.rb +38 -0
  36. data/lib/julewire/rails/structured_event_record.rb +128 -0
  37. data/lib/julewire/rails/subscribers/controller_response.rb +118 -0
  38. data/lib/julewire/rails/subscribers/error.rb +86 -0
  39. data/lib/julewire/rails/subscribers/event.rb +118 -0
  40. data/lib/julewire/rails/subscribers/rendered_exception.rb +141 -0
  41. data/lib/julewire/rails/suppression.rb +29 -0
  42. data/lib/julewire/rails/version.rb +7 -0
  43. data/lib/julewire/rails.rb +37 -0
  44. data/lib/julewire-rails.rb +3 -0
  45. metadata +201 -0
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Julewire
6
+ module Rails
7
+ module Subscribers
8
+ class ControllerResponse
9
+ EVENT_NAME = "process_action.action_controller"
10
+
11
+ class << self
12
+ include Core::Integration::SubscriberInstall
13
+
14
+ def install!(configuration)
15
+ return reset! unless configuration.controller_capture?
16
+
17
+ install_subscriber(configuration, enabled: true) do |subscriber|
18
+ subscription = ::ActiveSupport::Notifications.subscribe(EVENT_NAME) do |*arguments|
19
+ subscriber.process_action(::ActiveSupport::Notifications::Event.new(*arguments))
20
+ end
21
+ -> { ::ActiveSupport::Notifications.unsubscribe(subscription) }
22
+ end
23
+ end
24
+ end
25
+
26
+ def initialize(configuration = Configuration.new)
27
+ @configuration = configuration
28
+ end
29
+
30
+ attr_writer :configuration
31
+
32
+ def process_action(event)
33
+ return unless Julewire.current_execution?
34
+ return if Suppression.active?
35
+
36
+ IntegrationHealth.with_failure_health(action: :process_action, component: :controller_response_subscriber) do
37
+ fields = capture_attributes(event.payload)
38
+ Core::Integration::Facade.add_summary_attributes(fields[:attributes])
39
+ Core::Integration::Facade.add_summary_neutral(fields[:neutral])
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def capture_attributes(payload)
46
+ rails_fields = capture_fields(payload)
47
+ neutral = {}
48
+ response_body_bytes = rails_fields[:response_body_bytes]
49
+ if response_body_bytes
50
+ neutral = {
51
+ Core::Fields::AttributeKeys::HTTP_RESPONSE_BODY_SIZE => response_body_bytes
52
+ }
53
+ end
54
+ { attributes: { rails: rails_fields }, neutral: neutral }
55
+ end
56
+
57
+ def capture_fields(payload)
58
+ fields = {}
59
+ merge_capture_fields(fields, request_headers_fields(payload))
60
+ merge_capture_fields(
61
+ fields,
62
+ body_fields_for(:request, payload[:request], Julewire::Rack::Capture::RequestBody)
63
+ )
64
+ merge_capture_fields(fields, response_headers_fields(payload))
65
+ merge_capture_fields(
66
+ fields,
67
+ body_fields_for(:response, payload[:response], Julewire::Rack::Capture::BufferedResponseBody)
68
+ )
69
+ fields
70
+ end
71
+
72
+ def request_headers_fields(payload)
73
+ return unless @configuration.request_capture.headers?
74
+
75
+ capture_headers(:request_headers) do
76
+ Julewire::Rack::Capture::Headers.request(
77
+ payload[:request],
78
+ selector: @configuration.request_capture.headers
79
+ )
80
+ end
81
+ end
82
+
83
+ def response_headers_fields(payload)
84
+ response = payload[:response]
85
+ return unless @configuration.response_capture.headers? && response
86
+
87
+ capture_headers(:response_headers) do
88
+ Julewire::Rack::Capture::Headers.response(
89
+ response.headers,
90
+ selector: @configuration.response_capture.headers
91
+ )
92
+ end
93
+ end
94
+
95
+ def body_fields_for(type, target, capture)
96
+ capture_configuration = @configuration.public_send("#{type}_capture")
97
+ return unless capture_configuration.body?
98
+
99
+ capture.call(
100
+ target,
101
+ content_types: capture_configuration.body_content_types,
102
+ limit: capture_configuration.body_bytes,
103
+ mode: capture_configuration.body_mode
104
+ )
105
+ end
106
+
107
+ def capture_headers(key)
108
+ headers = yield
109
+ headers.empty? ? nil : { key => headers }
110
+ end
111
+
112
+ def merge_capture_fields(fields, captured)
113
+ fields.merge!(captured) if captured && !captured.empty?
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rails
5
+ module Subscribers
6
+ class Error
7
+ class << self
8
+ include Core::Integration::SubscriberInstall
9
+
10
+ def install!(configuration)
11
+ return reset! unless configuration.error_reports?
12
+ return unless defined?(::Rails) && ::Rails.respond_to?(:error)
13
+ return unless ::Rails.error.respond_to?(:subscribe)
14
+
15
+ reporter = ::Rails.error
16
+ install_subscriber(configuration, enabled: true) do |subscriber|
17
+ Julewire::RailsSupport::EventReporter.subscribe(reporter, subscriber)
18
+ end
19
+ end
20
+ end
21
+
22
+ def initialize(configuration = Configuration.new)
23
+ @configuration = configuration
24
+ end
25
+
26
+ attr_writer :configuration
27
+
28
+ def report(error, handled:, severity:, context:, source:)
29
+ return unless @configuration.error_reports?
30
+ return if Suppression.active?
31
+ return if request_owned_dispatch_error?(error, handled, source)
32
+
33
+ Core::Integration::Facade.emit(
34
+ severity: julewire_severity(severity),
35
+ event: "rails.error",
36
+ logger: "Rails.error",
37
+ source: @configuration.source,
38
+ context: hash_or_empty(context),
39
+ attributes: { rails: {
40
+ handled: handled,
41
+ source: source
42
+ } },
43
+ error: error
44
+ )
45
+ IntegrationHealth.record_success(action: :report, component: :error_subscriber)
46
+ rescue StandardError => e
47
+ IntegrationHealth.record_failure(
48
+ e,
49
+ action: :report,
50
+ component: :error_subscriber
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def request_owned_dispatch_error?(error, handled, source)
57
+ return false unless handled == false
58
+ return false unless source == "application.action_dispatch"
59
+
60
+ RequestErrorOwnership.consume?(error)
61
+ end
62
+
63
+ def julewire_severity(severity)
64
+ severity.to_sym == :warning ? :warn : severity
65
+ rescue StandardError
66
+ severity
67
+ end
68
+
69
+ def hash_or_empty(value)
70
+ return {} unless value.is_a?(Hash)
71
+
72
+ values = Core::Integration::Values::Shape
73
+ normalize_context(values.hash_or_empty(value))
74
+ end
75
+
76
+ def normalize_context(context)
77
+ controller = context[:controller]
78
+ return context unless context.key?(:controller)
79
+ return context if controller.nil? || controller.is_a?(String)
80
+
81
+ context.merge(controller: controller.class.name || controller.to_s)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rails
5
+ module Subscribers
6
+ class Event
7
+ include Core::Integration::EventSubscriber
8
+
9
+ STRUCTURED_EVENT_FILES = %w[
10
+ action_controller/structured_event_subscriber
11
+ action_dispatch/structured_event_subscriber
12
+ action_view/structured_event_subscriber
13
+ active_record/structured_event_subscriber
14
+ ].freeze
15
+
16
+ REQUEST_STARTED = "action_controller.request_started"
17
+ REQUEST_COMPLETED = "action_controller.request_completed"
18
+ REQUEST_CONTEXT_KEYS = %i[controller action format].freeze
19
+
20
+ event_subscriber integration_health: IntegrationHealth, configuration_class: Configuration
21
+
22
+ class << self
23
+ include Core::Integration::SubscriberInstall
24
+
25
+ def install!(configuration)
26
+ return reset! unless configuration.structured_events?
27
+
28
+ require_structured_event_subscribers
29
+ reporter = Julewire::RailsSupport::EventReporter.default
30
+ return unless Julewire::RailsSupport::EventReporter.subscribable?(reporter)
31
+
32
+ install_subscriber(configuration, enabled: true) do |subscriber|
33
+ Julewire::RailsSupport::EventReporter.subscribe(reporter, subscriber) do |event|
34
+ subscriber.accept?(event)
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def require_structured_event_subscribers
42
+ STRUCTURED_EVENT_FILES.each { Core::Integration::Lifecycle.require_optional(it) }
43
+ end
44
+ end
45
+
46
+ def accept?(event)
47
+ return false unless @configuration.structured_events?
48
+ return false if Suppression.active?
49
+
50
+ name = event[:name].to_s
51
+ return false if excluded_event?(name)
52
+
53
+ included_event?(name)
54
+ end
55
+
56
+ private
57
+
58
+ def emit_event(event)
59
+ name = event[:name].to_s
60
+ payload = event_record.payload_hash(event[:payload])
61
+
62
+ if name == REQUEST_STARTED && current_execution?
63
+ enrich_request_start(payload)
64
+ elsif name == REQUEST_COMPLETED && current_execution?
65
+ enrich_request_completion(payload)
66
+ else
67
+ Core::Integration::Facade.emit(event_record.call(event, name: name, payload: payload))
68
+ end
69
+ end
70
+
71
+ def after_configuration_change
72
+ @event_record = nil
73
+ end
74
+
75
+ def current_execution?
76
+ Julewire.current_execution?
77
+ end
78
+
79
+ def included_event?(name)
80
+ names = @configuration.structured_event_names
81
+ prefixes = @configuration.structured_event_prefixes
82
+ return true if prefixes.nil?
83
+ return true if Array(names).any? { name == it.to_s }
84
+
85
+ Array(prefixes).any? { name.start_with?(it.to_s) }
86
+ end
87
+
88
+ def excluded_event?(name)
89
+ Array(@configuration.structured_event_exclude_names).any? { name == it.to_s } ||
90
+ Array(@configuration.structured_event_exclude_prefixes).any? { name.start_with?(it.to_s) }
91
+ end
92
+
93
+ def enrich_request_start(payload)
94
+ fields = payload.slice(*REQUEST_CONTEXT_KEYS, :params).compact
95
+ add_summary_attributes(rails: fields)
96
+ end
97
+
98
+ def enrich_request_completion(payload)
99
+ add_summary_attributes(rails: request_completion_fields(payload))
100
+ end
101
+
102
+ def add_summary_attributes(fields)
103
+ Core::Integration::Facade.add_summary_attributes(fields)
104
+ end
105
+
106
+ def request_completion_fields(payload)
107
+ return payload unless payload.key?(:duration_ms)
108
+
109
+ payload.merge(action_runtime_ms: payload[:duration_ms]).except(:duration_ms)
110
+ end
111
+
112
+ def event_record
113
+ @event_record ||= StructuredEventRecord.new(@configuration)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch/middleware/debug_exceptions"
4
+
5
+ module Julewire
6
+ module Rails
7
+ module Subscribers
8
+ class RenderedException
9
+ class << self
10
+ include Core::Integration::SubscriberInstall
11
+
12
+ def install!(configuration)
13
+ return reset! unless configuration.request_summary? || configuration.rendered_exceptions?
14
+ return unless defined?(::ActionDispatch::DebugExceptions)
15
+ return unless ::ActionDispatch::DebugExceptions.respond_to?(:register_interceptor)
16
+
17
+ install_subscriber(configuration, enabled: true) do |subscriber|
18
+ ::ActionDispatch::DebugExceptions.register_interceptor(subscriber)
19
+ -> { unregister_interceptor(subscriber) }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def unregister_interceptor(subscriber)
26
+ return unless ::ActionDispatch::DebugExceptions.respond_to?(:interceptors)
27
+
28
+ ::ActionDispatch::DebugExceptions.interceptors.delete(subscriber)
29
+ end
30
+ end
31
+
32
+ def initialize(configuration = Configuration.new)
33
+ @configuration = configuration
34
+ end
35
+
36
+ attr_writer :configuration
37
+
38
+ def call(request, exception)
39
+ return if Suppression.active?
40
+
41
+ wrapper = exception_wrapper(request, exception)
42
+ return unless showable_response?(request, wrapper)
43
+
44
+ capture_request_error(request, exception, wrapper)
45
+ unless @configuration.rendered_exceptions?
46
+ IntegrationHealth.record_success(action: :call, component: :rendered_exception_subscriber)
47
+ return
48
+ end
49
+
50
+ Core::Integration::Facade.emit(
51
+ severity: severity_for(request, wrapper),
52
+ event: "action_dispatch.rendered_exception",
53
+ logger: "ActionDispatch::DebugExceptions",
54
+ source: @configuration.source,
55
+ attributes: attributes_for(request, wrapper),
56
+ neutral: neutral_for(request, wrapper),
57
+ error: exception
58
+ )
59
+ IntegrationHealth.record_success(action: :call, component: :rendered_exception_subscriber)
60
+ rescue StandardError => e
61
+ IntegrationHealth.record_failure(
62
+ e,
63
+ action: :call,
64
+ component: :rendered_exception_subscriber
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ def exception_wrapper(request, exception)
71
+ cleaner = request.get_header("action_dispatch.backtrace_cleaner")
72
+ ::ActionDispatch::ExceptionWrapper.new(cleaner, exception)
73
+ end
74
+
75
+ def showable_response?(request, wrapper)
76
+ wrapper.show?(request)
77
+ rescue StandardError
78
+ false
79
+ end
80
+
81
+ def capture_request_error(request, exception, wrapper)
82
+ return unless @configuration.request_summary?
83
+
84
+ request.set_header(
85
+ RequestMiddleware::RENDERED_EXCEPTION_ENV_KEY,
86
+ {
87
+ error: exception,
88
+ severity: severity_for(request, wrapper),
89
+ status: status_code(wrapper),
90
+ rescue_response: rescue_response?(wrapper),
91
+ rescue_template: rescue_template(wrapper)
92
+ }
93
+ )
94
+ RequestErrorOwnership.mark(exception)
95
+ end
96
+
97
+ def severity_for(request, _wrapper)
98
+ ExceptionSeverity.for_request(request)
99
+ end
100
+
101
+ def attributes_for(request, wrapper)
102
+ {
103
+ rails: {
104
+ rescue_response: rescue_response?(wrapper),
105
+ request_method: request.request_method,
106
+ path: request.path,
107
+ status: status_code(wrapper),
108
+ rescue_template: rescue_template(wrapper)
109
+ }.compact
110
+ }
111
+ end
112
+
113
+ def neutral_for(request, wrapper)
114
+ Core::Fields::AttributeKeys.fields(
115
+ Core::Fields::AttributeKeys::HTTP_REQUEST_METHOD => request.request_method,
116
+ Core::Fields::AttributeKeys::URL_PATH => request.path,
117
+ Core::Fields::AttributeKeys::HTTP_RESPONSE_STATUS_CODE => status_code(wrapper)
118
+ )
119
+ end
120
+
121
+ def status_code(wrapper)
122
+ wrapper.status_code
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
127
+ def rescue_response?(wrapper)
128
+ wrapper.rescue_response?
129
+ rescue StandardError
130
+ false
131
+ end
132
+
133
+ def rescue_template(wrapper)
134
+ wrapper.rescue_template
135
+ rescue StandardError
136
+ nil
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/isolated_execution_state"
4
+
5
+ module Julewire
6
+ module Rails
7
+ module Suppression
8
+ KEY = :julewire_rails_suppressed
9
+
10
+ class << self
11
+ def active?
12
+ !!::ActiveSupport::IsolatedExecutionState[KEY]
13
+ end
14
+
15
+ def suppress
16
+ previous = ::ActiveSupport::IsolatedExecutionState[KEY]
17
+ ::ActiveSupport::IsolatedExecutionState[KEY] = true
18
+ yield
19
+ ensure
20
+ if previous
21
+ ::ActiveSupport::IsolatedExecutionState[KEY] = previous
22
+ else
23
+ ::ActiveSupport::IsolatedExecutionState.delete(KEY)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rails
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "julewire/core"
5
+ require "julewire/rack"
6
+ require "julewire/rails_support"
7
+ require "rails"
8
+
9
+ module Julewire
10
+ module Rails
11
+ class Error < Julewire::Error; end
12
+ IntegrationHealth = Core::Integration::Health.scoped(:rails)
13
+
14
+ class << self
15
+ def config
16
+ application = ::Rails.application
17
+ raise Error, "Rails.application is not available" unless application
18
+
19
+ application.config.julewire_rails
20
+ end
21
+
22
+ def configure
23
+ raise ArgumentError, "Julewire::Rails.configure requires a block" unless block_given?
24
+
25
+ yield config
26
+ config
27
+ end
28
+ end
29
+ end
30
+
31
+ loader = Zeitwerk::Loader.for_gem_extension(self)
32
+ loader.setup
33
+ Core::Processing.register(:rails_parameter_filter) do |*args, **options|
34
+ Rails::ParameterFilterProcessor.new(*args, **options)
35
+ end
36
+ Julewire::Rails::Railtie
37
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "julewire/rails"