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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +94 -0
- data/docs/advanced-configuration.md +17 -0
- data/docs/capture-and-filtering.md +102 -0
- data/docs/configuration.md +83 -0
- data/docs/development.md +21 -0
- data/docs/events-and-errors.md +46 -0
- data/docs/lifecycle.md +24 -0
- data/docs/request-logging.md +49 -0
- data/julewire-rails.gemspec +44 -0
- data/lib/generators/julewire/install_generator.rb +15 -0
- data/lib/generators/julewire/templates/julewire.rb +16 -0
- data/lib/julewire/rails/configuration.rb +74 -0
- data/lib/julewire/rails/context_body_proxy.rb +54 -0
- data/lib/julewire/rails/debug_exception_log_silencer.rb +53 -0
- data/lib/julewire/rails/doctor_app.rb +233 -0
- data/lib/julewire/rails/exception_severity.rb +27 -0
- data/lib/julewire/rails/lifecycle_hooks.rb +76 -0
- data/lib/julewire/rails/log_subscriber_silencer.rb +52 -0
- data/lib/julewire/rails/logger.rb +185 -0
- data/lib/julewire/rails/logger_outputs.rb +36 -0
- data/lib/julewire/rails/output_requirement.rb +38 -0
- data/lib/julewire/rails/parameter_filter_plan.rb +100 -0
- data/lib/julewire/rails/parameter_filter_processor.rb +117 -0
- data/lib/julewire/rails/railtie.rb +84 -0
- data/lib/julewire/rails/request_attributes.rb +126 -0
- data/lib/julewire/rails/request_completion.rb +120 -0
- data/lib/julewire/rails/request_context.rb +91 -0
- data/lib/julewire/rails/request_error_ownership.rb +63 -0
- data/lib/julewire/rails/request_fields.rb +61 -0
- data/lib/julewire/rails/request_lifecycle.rb +109 -0
- data/lib/julewire/rails/request_middleware.rb +130 -0
- data/lib/julewire/rails/request_summary_timeout_scheduler.rb +38 -0
- data/lib/julewire/rails/structured_event_record.rb +128 -0
- data/lib/julewire/rails/subscribers/controller_response.rb +118 -0
- data/lib/julewire/rails/subscribers/error.rb +86 -0
- data/lib/julewire/rails/subscribers/event.rb +118 -0
- data/lib/julewire/rails/subscribers/rendered_exception.rb +141 -0
- data/lib/julewire/rails/suppression.rb +29 -0
- data/lib/julewire/rails/version.rb +7 -0
- data/lib/julewire/rails.rb +37 -0
- data/lib/julewire-rails.rb +3 -0
- metadata +201 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/logger"
|
|
4
|
+
|
|
5
|
+
module Julewire
|
|
6
|
+
module Rails
|
|
7
|
+
module LoggerOutputs
|
|
8
|
+
class << self
|
|
9
|
+
def install!
|
|
10
|
+
return if @installed
|
|
11
|
+
|
|
12
|
+
::ActiveSupport::Logger.singleton_class.prepend(Patch)
|
|
13
|
+
@installed = true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def julewire_logger?(logger)
|
|
17
|
+
loggers = logger.respond_to?(:broadcasts) ? logger.broadcasts : [logger]
|
|
18
|
+
loggers.any?(Logger)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def console_sources?(sources)
|
|
22
|
+
sources.any? { it.equal?($stdout) || it.equal?($stderr) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module Patch
|
|
27
|
+
def logger_outputs_to?(logger, *sources)
|
|
28
|
+
return true if LoggerOutputs.julewire_logger?(logger) && LoggerOutputs.console_sources?(sources)
|
|
29
|
+
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
private_constant :Patch
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Julewire
|
|
4
|
+
module Rails
|
|
5
|
+
module OutputRequirement
|
|
6
|
+
MESSAGE = "julewire-rails installed Rails.logger, but Julewire has no configured destinations. " \
|
|
7
|
+
"Configure Julewire.destinations or set config.julewire_rails.require_output = false."
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def check!(settings, health: Julewire.health, warning: Warning)
|
|
11
|
+
return unless settings.logger?
|
|
12
|
+
|
|
13
|
+
mode = normalized_mode(settings.require_output)
|
|
14
|
+
return if mode == false || health.dig(:pipeline, :configured)
|
|
15
|
+
|
|
16
|
+
case mode
|
|
17
|
+
when :warn
|
|
18
|
+
warning.warn("#{MESSAGE}\n")
|
|
19
|
+
when :raise
|
|
20
|
+
raise Error, MESSAGE
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def normalized_mode(value)
|
|
27
|
+
case value
|
|
28
|
+
when false, nil then false
|
|
29
|
+
when true, :warn, "warn" then :warn
|
|
30
|
+
when :raise, "raise" then :raise
|
|
31
|
+
else
|
|
32
|
+
raise Error, "config.julewire_rails.require_output must be false, :warn, or :raise"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Julewire
|
|
4
|
+
module Rails
|
|
5
|
+
class ParameterFilterPlan
|
|
6
|
+
FILTERED = ::ActiveSupport::ParameterFilter::FILTERED
|
|
7
|
+
RECORD_CONTAINER_KEYS = Core::Processing::RecordFieldTransform.container_keys
|
|
8
|
+
RECORD_SCALAR_KEYS = Core::Processing::RecordFieldTransform.scalar_keys
|
|
9
|
+
private_constant :FILTERED
|
|
10
|
+
private_constant :RECORD_CONTAINER_KEYS
|
|
11
|
+
private_constant :RECORD_SCALAR_KEYS
|
|
12
|
+
|
|
13
|
+
attr_reader :filtered_field_keys
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def build(filters)
|
|
17
|
+
filters = Array(filters)
|
|
18
|
+
return if filters.any? { it.is_a?(Regexp) || it.is_a?(Proc) }
|
|
19
|
+
|
|
20
|
+
new(filters)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(filters)
|
|
25
|
+
simple, deep = partition_filters(filters)
|
|
26
|
+
@filtered_field_keys = build_filtered_field_keys(simple, deep)
|
|
27
|
+
@direct_container_filter = simple.any? && deep.empty?
|
|
28
|
+
@simple_pattern = simple_filter_pattern(simple) if @direct_container_filter
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def direct_container_filter? = @direct_container_filter
|
|
32
|
+
|
|
33
|
+
def filter_value(value)
|
|
34
|
+
return filter_hash(value) if value.is_a?(Hash)
|
|
35
|
+
return filter_array(value) if value.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def partition_filters(filters)
|
|
43
|
+
filters.map { it.to_s.downcase }.partition { !it.include?(".") }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_filtered_field_keys(simple, deep)
|
|
47
|
+
(container_filter_keys(simple, deep) + scalar_filter_keys(simple)).uniq.freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def container_filter_keys(simple, deep)
|
|
51
|
+
return RECORD_CONTAINER_KEYS if simple.any?
|
|
52
|
+
|
|
53
|
+
deep.filter_map { it.split(".", 2).first&.to_sym }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def scalar_filter_keys(simple)
|
|
57
|
+
RECORD_SCALAR_KEYS.filter do |key|
|
|
58
|
+
name = key_name(key)
|
|
59
|
+
simple.any? { name.include?(it) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def simple_filter_pattern(filters)
|
|
64
|
+
Regexp.new(filters.map { Regexp.escape(it) }.join("|"), Regexp::IGNORECASE)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def filter_hash(value)
|
|
68
|
+
result = nil
|
|
69
|
+
value.each do |key, item|
|
|
70
|
+
filtered = simple_key_match?(key) ? FILTERED : filter_value(item)
|
|
71
|
+
next if filtered.equal?(item)
|
|
72
|
+
|
|
73
|
+
result ||= value.dup
|
|
74
|
+
result[key] = filtered
|
|
75
|
+
end
|
|
76
|
+
result || value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def filter_array(value)
|
|
80
|
+
result = nil
|
|
81
|
+
value.each_with_index do |item, index|
|
|
82
|
+
filtered = filter_value(item)
|
|
83
|
+
next if filtered.equal?(item)
|
|
84
|
+
|
|
85
|
+
result ||= value.dup
|
|
86
|
+
result[index] = filtered
|
|
87
|
+
end
|
|
88
|
+
result || value
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def simple_key_match?(key)
|
|
92
|
+
@simple_pattern.match?(key_name(key))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def key_name(key)
|
|
96
|
+
key.is_a?(Symbol) ? key.name : key.to_s
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/parameter_filter"
|
|
4
|
+
|
|
5
|
+
module Julewire
|
|
6
|
+
module Rails
|
|
7
|
+
class ParameterFilterProcessor
|
|
8
|
+
EMPTY_CONTAINER_MARKER = Core.sentinel(:empty_container)
|
|
9
|
+
private_constant :EMPTY_CONTAINER_MARKER
|
|
10
|
+
|
|
11
|
+
def initialize(filters = rails_filter_parameters)
|
|
12
|
+
@filter = build_filter(filters)
|
|
13
|
+
# Rails exposes filter_param for scalar fields; use it to avoid whole-record
|
|
14
|
+
# copies when the filter list has no Proc semantics to preserve. Regex-only
|
|
15
|
+
# filters fall back to whole-record filtering unless they can scalarize a
|
|
16
|
+
# required record container.
|
|
17
|
+
@filter_param_fast_path = filter_param_safe?(filters) && @filter.respond_to?(:filter_param)
|
|
18
|
+
@field_plan = ParameterFilterPlan.build(filters) if @filter_param_fast_path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(draft)
|
|
22
|
+
validate_draft!(draft)
|
|
23
|
+
return if @filter.nil?
|
|
24
|
+
|
|
25
|
+
@filter_param_fast_path ? filter_draft_fields!(draft) : filter_whole_record!(draft)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_draft!(draft)
|
|
31
|
+
return draft if draft.is_a?(Julewire::RecordDraft)
|
|
32
|
+
|
|
33
|
+
raise TypeError, "expected Julewire::RecordDraft"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_filter(filters)
|
|
37
|
+
return filters if filters.respond_to?(:filter) && !filters.is_a?(Array)
|
|
38
|
+
|
|
39
|
+
filters = Array(filters)
|
|
40
|
+
return if filters.empty?
|
|
41
|
+
|
|
42
|
+
::ActiveSupport::ParameterFilter.new(::ActiveSupport::ParameterFilter.precompile_filters(filters))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def filter_param_safe?(filters)
|
|
46
|
+
return false if filters.respond_to?(:filter) && !filters.is_a?(Array)
|
|
47
|
+
|
|
48
|
+
filters = Array(filters)
|
|
49
|
+
return false if filters.any?(Proc)
|
|
50
|
+
|
|
51
|
+
filters.none?(Regexp) || filters.any? { regexp_matches_record_container?(it) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def regexp_matches_record_container?(filter)
|
|
55
|
+
filter.is_a?(Regexp) && Core::Processing::RecordFieldTransform.container_keys.any? { filter.match?(it.name) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def filter_draft_fields!(draft)
|
|
59
|
+
each_filter_field_key(draft) do |key|
|
|
60
|
+
next unless draft.key?(key)
|
|
61
|
+
|
|
62
|
+
value = draft[key]
|
|
63
|
+
next if skip_empty_container?(key, value)
|
|
64
|
+
|
|
65
|
+
filtered = filter_record_param(key, value)
|
|
66
|
+
draft.transform_field!(key) { filtered } unless filtered.equal?(value)
|
|
67
|
+
end
|
|
68
|
+
draft
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def each_filter_field_key(draft, &)
|
|
72
|
+
return @field_plan.filtered_field_keys.each(&) if @field_plan&.filtered_field_keys
|
|
73
|
+
|
|
74
|
+
draft.each_key(&)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def filter_whole_record!(draft)
|
|
78
|
+
draft.transform_record! { @filter.filter(it) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def filter_record_param(key, value)
|
|
82
|
+
if @field_plan&.direct_container_filter? && record_container_key?(key) && value.is_a?(Hash)
|
|
83
|
+
return @field_plan.filter_value(value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
filtered = @filter.filter_param(key, value)
|
|
87
|
+
return filtered unless record_container_key?(key) && !filtered.is_a?(Hash)
|
|
88
|
+
return value unless value.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
@filter.filter(value)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def skip_empty_container?(key, value)
|
|
94
|
+
return false unless empty_container?(value)
|
|
95
|
+
return true if record_container_key?(key)
|
|
96
|
+
|
|
97
|
+
@filter.filter_param(key, EMPTY_CONTAINER_MARKER).equal?(EMPTY_CONTAINER_MARKER)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def empty_container?(value)
|
|
101
|
+
(value.is_a?(Hash) || value.is_a?(Array)) && value.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def record_container_key?(key)
|
|
105
|
+
Core::Processing::RecordFieldTransform.container_key?(key)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def rails_filter_parameters
|
|
109
|
+
app = ::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
|
|
110
|
+
config = app.config if app.respond_to?(:config)
|
|
111
|
+
config.filter_parameters if config.respond_to?(:filter_parameters)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/tagged_logging"
|
|
4
|
+
require "rails/rack/logger"
|
|
5
|
+
|
|
6
|
+
module Julewire
|
|
7
|
+
module Rails
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
config.julewire_rails = Configuration.new
|
|
10
|
+
|
|
11
|
+
initializer "julewire_rails.logger", before: :initialize_logger do |app|
|
|
12
|
+
settings = app.config.julewire_rails
|
|
13
|
+
settings.validate!
|
|
14
|
+
next unless settings.logger?
|
|
15
|
+
|
|
16
|
+
logger = Logger.new(name: settings.logger_name, source: settings.source)
|
|
17
|
+
logger.level = ::Logger::Severity.const_get(app.config.log_level.to_s.upcase)
|
|
18
|
+
logger.formatter = app.config.log_formatter if app.config.respond_to?(:log_formatter)
|
|
19
|
+
app.config.logger = ::ActiveSupport::TaggedLogging.new(logger)
|
|
20
|
+
LoggerOutputs.install!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
initializer "julewire_rails.request_middleware", before: :build_middleware_stack do |app|
|
|
24
|
+
settings = app.config.julewire_rails
|
|
25
|
+
settings.validate!
|
|
26
|
+
next unless settings.request_middleware?
|
|
27
|
+
|
|
28
|
+
self.class.install_request_middleware(app, settings, app.config.log_tags)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
initializer "julewire_rails.exception_logging", before: :build_middleware_stack do |app|
|
|
32
|
+
settings = app.config.julewire_rails
|
|
33
|
+
settings.validate!
|
|
34
|
+
self.class.configure_exception_logging(app, settings)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
config.after_initialize do |app|
|
|
38
|
+
settings = app.config.julewire_rails
|
|
39
|
+
settings.validate!
|
|
40
|
+
OutputRequirement.check!(settings)
|
|
41
|
+
LifecycleHooks.install!(settings)
|
|
42
|
+
Railtie.install_subscribers(settings)
|
|
43
|
+
DebugExceptionLogSilencer.install!(settings)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
def install_subscribers(settings)
|
|
48
|
+
Subscribers::ControllerResponse.install!(settings)
|
|
49
|
+
settings.error_reports? ? Subscribers::Error.install!(settings) : Subscribers::Error.reset!
|
|
50
|
+
Subscribers::RenderedException.install!(settings)
|
|
51
|
+
if settings.structured_events?
|
|
52
|
+
Subscribers::Event.install!(settings)
|
|
53
|
+
LogSubscriberSilencer.silence! if settings.silence_log_subscribers?
|
|
54
|
+
else
|
|
55
|
+
Subscribers::Event.reset!
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def install_request_middleware(app, settings, log_tags = nil)
|
|
60
|
+
operation = settings.replace_rack_logger? ? :swap : :insert_after
|
|
61
|
+
app.config.middleware.public_send(operation, ::Rails::Rack::Logger, RequestMiddleware, settings, log_tags)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
IntegrationHealth.record_failure(e, component: :request_middleware, action: :install)
|
|
64
|
+
raise
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def configure_exception_logging(app, settings)
|
|
68
|
+
value = log_rescued_responses_value(settings)
|
|
69
|
+
app.config.action_dispatch.log_rescued_responses = value unless value.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def log_rescued_responses_value(settings)
|
|
73
|
+
if settings.log_rescued_responses == :auto
|
|
74
|
+
return false if settings.logger? && settings.request_summary?
|
|
75
|
+
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
settings.log_rescued_responses
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Julewire
|
|
4
|
+
module Rails
|
|
5
|
+
module RequestAttributes
|
|
6
|
+
class << self
|
|
7
|
+
def context_fields(request)
|
|
8
|
+
request = request_fields(request)
|
|
9
|
+
values = Core::Integration::Values::Shape
|
|
10
|
+
{}.tap do |fields|
|
|
11
|
+
values.append_field(fields, :request_id, request.id)
|
|
12
|
+
values.append_field(fields, :http_method, request.method)
|
|
13
|
+
values.append_field(fields, :path, request.path)
|
|
14
|
+
values.append_field(fields, :remote_ip, request.remote_ip)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def request(request)
|
|
19
|
+
request_neutral_attributes(request)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def response_summary(request, status, headers)
|
|
23
|
+
{
|
|
24
|
+
attributes: { rails: rails_response_attributes(request, status, headers) },
|
|
25
|
+
neutral: neutral_fields(Core::Fields::AttributeKeys::HTTP_RESPONSE_STATUS_CODE => status)
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rendered_error_summary(request, rendered_error, status:)
|
|
30
|
+
error_summary(
|
|
31
|
+
request,
|
|
32
|
+
rendered_error.fetch(:error),
|
|
33
|
+
status: status,
|
|
34
|
+
wrapper: nil
|
|
35
|
+
).tap do |fields|
|
|
36
|
+
fields[:attributes][:rails][:rescue_response] = rendered_error[:rescue_response]
|
|
37
|
+
fields[:attributes][:rails][:rescue_template] = rendered_error[:rescue_template]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def error_summary(request, error, status:, wrapper:)
|
|
42
|
+
{
|
|
43
|
+
attributes: { rails: rails_error_attributes(request, error, status: status, wrapper: wrapper) },
|
|
44
|
+
neutral: neutral_fields(Core::Fields::AttributeKeys::HTTP_RESPONSE_STATUS_CODE => status)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def request_id(request)
|
|
49
|
+
request_fields(request).id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def request_neutral_attributes(request)
|
|
55
|
+
request = request_fields(request)
|
|
56
|
+
values = Core::Integration::Values::Shape
|
|
57
|
+
fields = {}
|
|
58
|
+
values.append_field(
|
|
59
|
+
fields,
|
|
60
|
+
Core::Fields::AttributeKeys::HTTP_REQUEST_METHOD,
|
|
61
|
+
request.method
|
|
62
|
+
)
|
|
63
|
+
values.append_field(fields, Core::Fields::AttributeKeys::URL_FULL, request.filtered_url)
|
|
64
|
+
values.append_field(fields, Core::Fields::AttributeKeys::URL_PATH, request.path)
|
|
65
|
+
values.append_field(
|
|
66
|
+
fields,
|
|
67
|
+
Core::Fields::AttributeKeys::USER_AGENT_ORIGINAL,
|
|
68
|
+
request.user_agent
|
|
69
|
+
)
|
|
70
|
+
values.append_field(fields, Core::Fields::AttributeKeys::CLIENT_ADDRESS, request.remote_ip)
|
|
71
|
+
fields
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def rails_response_attributes(request, status, headers)
|
|
75
|
+
rails_request_attributes(request).tap do |rails|
|
|
76
|
+
values = Core::Integration::Values::Shape
|
|
77
|
+
values.append_field(rails, :status, status)
|
|
78
|
+
values.append_field(rails, :response_content_type, response_header(headers, "content-type"))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def rails_error_attributes(request, error, status:, wrapper:)
|
|
83
|
+
rails_request_attributes(request).tap do |rails|
|
|
84
|
+
values = Core::Integration::Values::Shape
|
|
85
|
+
values.append_field(rails, :error_class, error.class.name)
|
|
86
|
+
values.append_field(rails, :status, status)
|
|
87
|
+
values.append_field(rails, :rescue_response, rescue_response?(wrapper))
|
|
88
|
+
values.append_field(rails, :rescue_template, rescue_template(wrapper))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def rails_request_attributes(request)
|
|
93
|
+
request = request_fields(request)
|
|
94
|
+
values = Core::Integration::Values::Shape
|
|
95
|
+
{}.tap do |fields|
|
|
96
|
+
values.append_field(fields, :filtered_url, request.filtered_url)
|
|
97
|
+
values.append_field(fields, :filtered_path, request.filtered_path)
|
|
98
|
+
values.append_field(fields, :request_method, request.method)
|
|
99
|
+
values.append_field(fields, :path, request.path)
|
|
100
|
+
values.append_field(fields, :user_agent, request.user_agent)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def rescue_response?(wrapper)
|
|
105
|
+
wrapper.rescue_response?
|
|
106
|
+
rescue StandardError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def rescue_template(wrapper)
|
|
111
|
+
wrapper.rescue_template
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def response_header(headers, key)
|
|
117
|
+
Julewire::Rack::Capture::BodyContentType.header_value(headers, key)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def request_fields(request) = RequestFields.new(request)
|
|
121
|
+
|
|
122
|
+
def neutral_fields(fields) = Core::Fields::AttributeKeys.fields(fields)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Julewire
|
|
4
|
+
module Rails
|
|
5
|
+
class RequestCompletion
|
|
6
|
+
class << self
|
|
7
|
+
def finish_instrumentation(instrumenter_handle)
|
|
8
|
+
instrumenter_handle&.finish
|
|
9
|
+
rescue StandardError
|
|
10
|
+
nil
|
|
11
|
+
ensure
|
|
12
|
+
begin
|
|
13
|
+
::ActiveSupport::LogSubscriber.flush_all!
|
|
14
|
+
rescue StandardError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(configuration:, execution_handle:, instrumenter_handle:, env:, request:, request_error:)
|
|
21
|
+
@configuration = configuration
|
|
22
|
+
@execution_handle = execution_handle
|
|
23
|
+
@instrumenter_handle = instrumenter_handle
|
|
24
|
+
@env = env
|
|
25
|
+
@request = request
|
|
26
|
+
@request_error = request_error
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def attach(response)
|
|
30
|
+
status, headers, body = response
|
|
31
|
+
timeout_token = nil
|
|
32
|
+
finish_once = completion_callback { RequestSummaryTimeoutScheduler.cancel(timeout_token) }
|
|
33
|
+
install_response_finished_callback(finish_once)
|
|
34
|
+
timeout_token = install_completion_timeout
|
|
35
|
+
body = ContextBodyProxy.new(body, handle: @execution_handle, on_close: -> { finish_once.call(nil) })
|
|
36
|
+
response.frozen? ? [status, headers, body] : response.tap { it[2] = body }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def install_completion_timeout
|
|
42
|
+
timeout = @configuration.request_summary_timeout
|
|
43
|
+
return unless timeout
|
|
44
|
+
|
|
45
|
+
context = completion_timeout_context
|
|
46
|
+
RequestSummaryTimeoutScheduler.schedule(timeout) { emit_completion_timeout_warning(timeout, context) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def install_response_finished_callback(finish_once)
|
|
50
|
+
response_finished = @env["rack.response_finished"]
|
|
51
|
+
return unless response_finished.respond_to?(:<<)
|
|
52
|
+
|
|
53
|
+
response_finished << proc do |_rack_env, _status, _headers, error|
|
|
54
|
+
finish_once.call(error)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def completion_callback
|
|
59
|
+
mutex = Mutex.new
|
|
60
|
+
finished = false
|
|
61
|
+
lambda do |error|
|
|
62
|
+
mutex.synchronize do
|
|
63
|
+
return if finished
|
|
64
|
+
|
|
65
|
+
finished = true
|
|
66
|
+
end
|
|
67
|
+
yield if block_given?
|
|
68
|
+
@execution_handle.with_context { finish_completion(error) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def finish_completion(error)
|
|
73
|
+
self.class.finish_instrumentation(@instrumenter_handle)
|
|
74
|
+
if error
|
|
75
|
+
@execution_handle.finish(
|
|
76
|
+
reason: :error,
|
|
77
|
+
attributes: { rails: { completion: "error", completion_error_class: error.class.name } },
|
|
78
|
+
error: error
|
|
79
|
+
)
|
|
80
|
+
elsif @request_error
|
|
81
|
+
@execution_handle.finish(
|
|
82
|
+
reason: :error,
|
|
83
|
+
attributes: { rails: { completion: "error" } },
|
|
84
|
+
error: @request_error.fetch(:error),
|
|
85
|
+
severity: @request_error.fetch(:severity)
|
|
86
|
+
)
|
|
87
|
+
else
|
|
88
|
+
@execution_handle.finish(reason: :closed, attributes: { rails: { completion: "closed" } })
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def completion_timeout_context
|
|
93
|
+
return {} unless @configuration.request_context?
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
request_id: request_id,
|
|
97
|
+
path: @request.path
|
|
98
|
+
}.compact
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def emit_completion_timeout_warning(timeout, context)
|
|
102
|
+
record = {
|
|
103
|
+
event: "request.completion_timeout",
|
|
104
|
+
logger: @configuration.logger_name,
|
|
105
|
+
source: @configuration.source,
|
|
106
|
+
attributes: { rails: { completion_timeout_ms: (timeout * 1000).round } }
|
|
107
|
+
}
|
|
108
|
+
record[:context] = context unless context.empty?
|
|
109
|
+
Julewire.warn(record)
|
|
110
|
+
rescue StandardError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def request_id
|
|
115
|
+
value = @request.request_id if @request.respond_to?(:request_id)
|
|
116
|
+
value || @request.get_header("action_dispatch.request_id") || @request.get_header("HTTP_X_REQUEST_ID")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|