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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rails
5
+ class RequestContext
6
+ def initialize(configuration:, request:, active_support_context: Core::UNSET, event_reporter: Core::UNSET)
7
+ @configuration = configuration
8
+ @request = request
9
+ @active_support_context = default_provider(active_support_context) { ::ActiveSupport::ExecutionContext }
10
+ @event_reporter = default_provider(event_reporter) { Julewire::RailsSupport::EventReporter.default }
11
+ end
12
+
13
+ def neutral_fields
14
+ RequestAttributes.request(@request)
15
+ end
16
+
17
+ def call(&)
18
+ with_request_carry do
19
+ with_request_context(&)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def default_provider(value)
26
+ value.equal?(Core::UNSET) ? yield : value
27
+ end
28
+
29
+ def with_request_context(&)
30
+ return yield unless @configuration.request_context?
31
+
32
+ fields = RequestAttributes.context_fields(@request)
33
+ Core::Integration::Facade.with_context(fields) do
34
+ with_active_support_execution_context(fields) do
35
+ with_rails_event_context(fields, &)
36
+ end
37
+ end
38
+ end
39
+
40
+ def with_request_carry(&)
41
+ return yield unless @configuration.carry_request_headers
42
+ raise ArgumentError, "carry_request_headers must be an explicit header list" if all_carry_headers?
43
+
44
+ headers = Julewire::Rack::Capture::Headers.request(@request, selector: @configuration.carry_request_headers)
45
+ return yield if headers.empty?
46
+
47
+ Core::Integration::Facade.with_carry(http: { request_headers: headers }, &)
48
+ end
49
+
50
+ def all_carry_headers?
51
+ @configuration.carry_request_headers == true
52
+ end
53
+
54
+ def with_active_support_execution_context(fields, &)
55
+ return yield unless @active_support_context.respond_to?(:set)
56
+
57
+ @active_support_context.set(**fields, &)
58
+ end
59
+
60
+ def with_rails_event_context(fields)
61
+ return yield unless rails_event_context_supported?
62
+
63
+ previous = nil
64
+ context_set = false
65
+ begin
66
+ previous = @event_reporter.context
67
+ @event_reporter.set_context(fields)
68
+ context_set = true
69
+ rescue StandardError
70
+ return yield
71
+ end
72
+ yield
73
+ ensure
74
+ restore_rails_event_context(previous) if context_set
75
+ end
76
+
77
+ def rails_event_context_supported?
78
+ @event_reporter.respond_to?(:context) &&
79
+ @event_reporter.respond_to?(:set_context) &&
80
+ @event_reporter.respond_to?(:clear_context)
81
+ end
82
+
83
+ def restore_rails_event_context(previous)
84
+ @event_reporter.clear_context
85
+ @event_reporter.set_context(previous) unless previous.nil? || previous.empty?
86
+ rescue StandardError
87
+ nil
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/isolated_execution_state"
4
+
5
+ module Julewire
6
+ module Rails
7
+ module RequestErrorOwnership
8
+ KEY = :julewire_rails_request_error_objects
9
+ private_constant :KEY
10
+
11
+ class << self
12
+ def clear
13
+ ::ActiveSupport::IsolatedExecutionState.delete(KEY)
14
+ rescue StandardError
15
+ nil
16
+ end
17
+
18
+ def mark(error)
19
+ return unless error
20
+
21
+ errors = error_map
22
+ each_exception(error) { errors[it] = true }
23
+ rescue StandardError
24
+ nil
25
+ end
26
+
27
+ def consume?(error)
28
+ errors = current_error_map
29
+ return false unless errors
30
+
31
+ each_exception(error).any? { errors.delete(it) }
32
+ rescue StandardError
33
+ false
34
+ end
35
+
36
+ private
37
+
38
+ def error_map
39
+ current_error_map || set_error_map
40
+ end
41
+
42
+ def set_error_map
43
+ ObjectSpace::WeakKeyMap.new.tap { ::ActiveSupport::IsolatedExecutionState[KEY] = it }
44
+ end
45
+
46
+ def current_error_map
47
+ ::ActiveSupport::IsolatedExecutionState[KEY]
48
+ end
49
+
50
+ def each_exception(error)
51
+ return enum_for(:each_exception, error) unless block_given?
52
+
53
+ seen = {}.compare_by_identity
54
+ while error && !seen.key?(error)
55
+ yield error
56
+ seen[error] = true
57
+ error = error.cause
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rails
5
+ class RequestFields
6
+ REMOTE_IP_ENV_KEY = "julewire.rails.remote_ip"
7
+
8
+ def initialize(request)
9
+ @request = request
10
+ end
11
+
12
+ def id
13
+ value = @request.request_id if @request.respond_to?(:request_id)
14
+ value || header("action_dispatch.request_id") || header("HTTP_X_REQUEST_ID")
15
+ rescue StandardError
16
+ nil
17
+ end
18
+
19
+ def method
20
+ @request.request_method
21
+ end
22
+
23
+ def path
24
+ @request.path
25
+ end
26
+
27
+ def filtered_path
28
+ @request.filtered_path
29
+ end
30
+
31
+ def filtered_url
32
+ "#{@request.protocol}#{@request.host_with_port}#{filtered_path}"
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def user_agent
38
+ header("HTTP_USER_AGENT")
39
+ end
40
+
41
+ def remote_ip
42
+ env = @request.env if @request.respond_to?(:env)
43
+ return env[REMOTE_IP_ENV_KEY] if env&.key?(REMOTE_IP_ENV_KEY)
44
+
45
+ value = @request.remote_ip
46
+ env[REMOTE_IP_ENV_KEY] = value if env
47
+ value
48
+ rescue StandardError
49
+ nil
50
+ end
51
+
52
+ private
53
+
54
+ def header(key)
55
+ @request.get_header(key)
56
+ rescue StandardError
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rails
5
+ class RequestLifecycle
6
+ attr_reader :execution_handle
7
+
8
+ def initialize(configuration:, env:, request:, taggers:)
9
+ @configuration = configuration
10
+ @env = env
11
+ @request = request
12
+ @taggers = taggers
13
+ @completion_attached = false
14
+ @execution_handle = nil
15
+ @instrumenter_handle = nil
16
+ @tag_count = 0
17
+ end
18
+
19
+ def start
20
+ @tag_count = push_tags
21
+ @instrumenter_handle = start_request_instrumentation
22
+ self
23
+ end
24
+
25
+ def start_execution!(neutral:)
26
+ @execution_handle = Julewire.start_execution(
27
+ type: :request,
28
+ id: RequestAttributes.request_id(@request),
29
+ neutral: neutral,
30
+ emit_summary: @configuration.request_summary?,
31
+ summary_event: @configuration.summary_event,
32
+ summary_source: @configuration.source
33
+ )
34
+ end
35
+
36
+ def attach_body_finalizer(response)
37
+ finish_request_thread_logging
38
+ @completion_attached = true
39
+ RequestCompletion.new(
40
+ configuration: @configuration,
41
+ execution_handle: @execution_handle,
42
+ instrumenter_handle: @instrumenter_handle,
43
+ env: @env,
44
+ request: @request,
45
+ request_error: @env[RequestMiddleware::REQUEST_ERROR_ENV_KEY]
46
+ ).attach(response)
47
+ end
48
+
49
+ def finish_error(error)
50
+ @execution_handle&.finish(reason: :error, error: error)
51
+ end
52
+
53
+ def finish_unattached
54
+ return if @completion_attached
55
+
56
+ finish_unattached_request
57
+ finish_request_thread_logging
58
+ end
59
+
60
+ private
61
+
62
+ def push_tags
63
+ return 0 unless ::Rails.logger.respond_to?(:push_tags)
64
+
65
+ ::Rails.logger.push_tags(*compute_tags).size
66
+ end
67
+
68
+ def compute_tags
69
+ @taggers.collect do |tag|
70
+ case tag
71
+ when Proc
72
+ tag.call(@request)
73
+ when Symbol
74
+ @request.public_send(tag)
75
+ else
76
+ tag
77
+ end
78
+ end
79
+ end
80
+
81
+ def start_request_instrumentation
82
+ handle = ::ActiveSupport::Notifications.instrumenter.build_handle(
83
+ "request.action_dispatch",
84
+ request: @request
85
+ )
86
+ handle.start
87
+ handle
88
+ end
89
+
90
+ def finish_request_thread_logging
91
+ return unless @tag_count.to_i.positive? && ::Rails.logger.respond_to?(:pop_tags)
92
+
93
+ ::Rails.logger.pop_tags(@tag_count)
94
+ @tag_count = 0
95
+ rescue StandardError
96
+ nil
97
+ end
98
+
99
+ def finish_unattached_request
100
+ return RequestCompletion.finish_instrumentation(@instrumenter_handle) unless @execution_handle
101
+
102
+ @execution_handle.with_context do
103
+ RequestCompletion.finish_instrumentation(@instrumenter_handle)
104
+ @execution_handle.finish(reason: :closed)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch/http/request"
4
+ require "action_dispatch/middleware/exception_wrapper"
5
+ require "action_view"
6
+ require "active_support/log_subscriber"
7
+
8
+ module Julewire
9
+ module Rails
10
+ class RequestMiddleware
11
+ REQUEST_ERROR_ENV_KEY = "julewire.rails.request_error"
12
+ RENDERED_EXCEPTION_ENV_KEY = "julewire.rails.rendered_exception"
13
+
14
+ def initialize(app, configuration = Configuration.new, taggers = nil)
15
+ @app = app
16
+ @configuration = configuration
17
+ @taggers = taggers || []
18
+ end
19
+
20
+ def call(env)
21
+ lifecycle = nil
22
+ request = ::ActionDispatch::Request.new(env)
23
+ RequestErrorOwnership.clear
24
+ return Suppression.suppress { @app.call(env) } if excluded_request?(request)
25
+
26
+ lifecycle = RequestLifecycle.new(
27
+ configuration: @configuration,
28
+ env: env,
29
+ request: request,
30
+ taggers: @taggers
31
+ ).start
32
+ request_context = RequestContext.new(configuration: @configuration, request: request)
33
+
34
+ response = request_context.call do
35
+ execution_handle = lifecycle.start_execution!(neutral: request_context.neutral_fields)
36
+ execution_handle.run do
37
+ call_app(request, env, execution_handle)
38
+ end
39
+ end
40
+
41
+ lifecycle.attach_body_finalizer(response)
42
+ rescue Exception => e # rubocop:disable Lint/RescueException -- Rack middleware must re-raise all application exits.
43
+ lifecycle&.finish_error(e)
44
+ raise
45
+ ensure
46
+ lifecycle&.finish_unattached
47
+ end
48
+
49
+ private
50
+
51
+ def excluded_request?(request)
52
+ @configuration.request_exclude_prefixes.any? { excluded_path?(request.path, it) }
53
+ end
54
+
55
+ def excluded_path?(path, prefix)
56
+ return true if prefix == "/"
57
+
58
+ path == prefix || path.start_with?("#{prefix}/")
59
+ end
60
+
61
+ def call_app(request, env, execution_handle)
62
+ status, headers, body = @app.call(env)
63
+ add_summary_fields(response_summary_attributes(request, status, headers))
64
+ capture_rendered_request_error(request, env, status)
65
+ [status, headers, body]
66
+ rescue Exception => e # rubocop:disable Lint/RescueException -- Rack middleware must re-raise all application exits.
67
+ severity = request_exception_severity(request)
68
+ wrapper = exception_wrapper(request, e)
69
+ own_request_error(env, e, severity: severity)
70
+ add_summary_fields(error_summary_attributes(request, e, status: 500, wrapper: wrapper))
71
+ execution_handle.finish(reason: :error, error: e, severity: severity)
72
+ raise
73
+ end
74
+
75
+ def response_summary_attributes(request, status, headers)
76
+ RequestAttributes.response_summary(request, status, headers)
77
+ end
78
+
79
+ def capture_rendered_request_error(request, env, status)
80
+ rendered_error = env[RENDERED_EXCEPTION_ENV_KEY]
81
+ if rendered_error
82
+ own_request_error(env, rendered_error.fetch(:error), severity: rendered_error.fetch(:severity))
83
+ add_summary_fields(rendered_error_summary_attributes(request, rendered_error, status: status))
84
+ return
85
+ end
86
+
87
+ error = env["action_dispatch.exception"]
88
+ return unless error
89
+
90
+ severity = request_exception_severity(request)
91
+ wrapper = exception_wrapper(request, error)
92
+ own_request_error(env, error, severity: severity)
93
+ add_summary_fields(error_summary_attributes(request, error, status: status, wrapper: wrapper))
94
+ end
95
+
96
+ def own_request_error(env, error, severity:)
97
+ return unless @configuration.request_summary?
98
+
99
+ env[REQUEST_ERROR_ENV_KEY] = { error: error, severity: severity }
100
+ RequestErrorOwnership.mark(error)
101
+ end
102
+
103
+ def rendered_error_summary_attributes(request, rendered_error, status:)
104
+ RequestAttributes.rendered_error_summary(
105
+ request,
106
+ rendered_error,
107
+ status: status
108
+ )
109
+ end
110
+
111
+ def error_summary_attributes(request, error, status:, wrapper:)
112
+ RequestAttributes.error_summary(request, error, status: status, wrapper: wrapper)
113
+ end
114
+
115
+ def request_exception_severity(request)
116
+ ExceptionSeverity.for_request(request)
117
+ end
118
+
119
+ def exception_wrapper(request, error)
120
+ cleaner = request.get_header("action_dispatch.backtrace_cleaner")
121
+ ::ActionDispatch::ExceptionWrapper.new(cleaner, error)
122
+ end
123
+
124
+ def add_summary_fields(fields)
125
+ Core::Integration::Facade.add_summary_attributes(fields[:attributes])
126
+ Core::Integration::Facade.add_summary_neutral(fields[:neutral])
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Rails
5
+ module RequestSummaryTimeoutScheduler
6
+ class << self
7
+ def schedule(timeout, &block)
8
+ return unless timeout && block
9
+
10
+ Core::Scheduling::SharedScheduler.schedule(timeout, &block)
11
+ rescue StandardError
12
+ nil
13
+ end
14
+
15
+ def cancel(token)
16
+ return unless token
17
+
18
+ Core::Scheduling::SharedScheduler.cancel(token)
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ # Private testing seam for request-timeout isolation.
24
+ def reset_for_test!
25
+ Core::Scheduling::SharedScheduler.__send__(:reset_for_test!)
26
+ nil
27
+ end
28
+
29
+ def after_fork!
30
+ Core::Scheduling::SharedScheduler.after_fork!
31
+ nil
32
+ end
33
+
34
+ private :reset_for_test!
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/parameter_filter"
4
+
5
+ module Julewire
6
+ module Rails
7
+ class StructuredEventRecord
8
+ DEBUG_EVENT_PREFIXES = %w[
9
+ action_view.
10
+ active_record.
11
+ ].freeze
12
+
13
+ DEBUG_EVENTS = %w[
14
+ action_controller.unpermitted_parameters
15
+ ].freeze
16
+
17
+ def initialize(configuration, parameter_filter: Core::UNSET)
18
+ @configuration = configuration
19
+ @parameter_filter_override = parameter_filter
20
+ end
21
+
22
+ def call(event, name:, payload:)
23
+ values = Core::Integration::Values::Shape
24
+ {
25
+ timestamp: values.timestamp(event[:timestamp]),
26
+ severity: severity_for(name),
27
+ event: name,
28
+ logger: "Rails.event",
29
+ source: @configuration.source,
30
+ context: values.hash_or_empty(event[:context]),
31
+ attributes: attributes_for(event, payload),
32
+ neutral: neutral_for(event)
33
+ }.compact
34
+ end
35
+
36
+ def payload_hash(payload)
37
+ case payload
38
+ when nil
39
+ {}
40
+ when Hash
41
+ values = Core::Integration::Values::Shape
42
+ values.payload_hash(payload)
43
+ else
44
+ serialize_payload_object(payload)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def attributes_for(event, payload)
51
+ values = Core::Integration::Values::Shape
52
+ rails = payload.empty? ? {} : payload
53
+ values.append_compact_field(rails, :tags, values.hash_or_empty(event[:tags]))
54
+ { rails: rails }
55
+ end
56
+
57
+ def neutral_for(event)
58
+ values = Core::Integration::Values::Shape
59
+ values.source_location_attributes(event[:source_location])
60
+ end
61
+
62
+ def severity_for(name)
63
+ return :debug if DEBUG_EVENTS.include?(name)
64
+ return :debug if DEBUG_EVENT_PREFIXES.any? { name.start_with?(it) }
65
+
66
+ :info
67
+ end
68
+
69
+ def serialize_payload_object(payload)
70
+ if payload.respond_to?(:serialize)
71
+ serialized = payload.serialize
72
+ return object_payload_hash(serialized) if serialized.is_a?(Hash)
73
+
74
+ { Julewire::Core::Fields::FieldSet::VALUE_KEY => serialized }
75
+ else
76
+ { Julewire::Core::Fields::FieldSet::VALUE_KEY => payload }
77
+ end
78
+ rescue StandardError => e
79
+ {
80
+ Julewire::Core::Fields::FieldSet::VALUE_KEY => payload,
81
+ serialize_error_class: e.class.name
82
+ }
83
+ end
84
+
85
+ def object_payload_hash(serialized)
86
+ Julewire::Core::Fields::FieldSet.deep_symbolize_keys(filter_event_payload(serialized))
87
+ end
88
+
89
+ def filter_event_payload(payload)
90
+ return payload unless @configuration.filter_event_payloads?
91
+
92
+ filter = rails_parameter_filter
93
+ return payload unless filter
94
+
95
+ filtered = filter.filter(payload)
96
+ filtered.is_a?(Hash) ? filtered : payload
97
+ rescue StandardError
98
+ payload
99
+ end
100
+
101
+ def rails_parameter_filter
102
+ return @parameter_filter_override unless @parameter_filter_override.equal?(Core::UNSET)
103
+ return @rails_parameter_filter if @rails_parameter_filter_loaded
104
+
105
+ @rails_parameter_filter_loaded = true
106
+ filters = rails_filter_parameters
107
+ @rails_parameter_filter = build_parameter_filter(filters)
108
+ end
109
+
110
+ def build_parameter_filter(filters)
111
+ return if filters.empty?
112
+
113
+ ::ActiveSupport::ParameterFilter.new(::ActiveSupport::ParameterFilter.precompile_filters(filters))
114
+ end
115
+
116
+ def rails_filter_parameters
117
+ app = ::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
118
+ return Array(app.filter_parameters) if app.respond_to?(:filter_parameters)
119
+
120
+ config = app.config if app.respond_to?(:config)
121
+ filters = config.filter_parameters if config.respond_to?(:filter_parameters)
122
+ Array(filters)
123
+ rescue StandardError
124
+ []
125
+ end
126
+ end
127
+ end
128
+ end