goodsorcery 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/README.md +299 -0
- data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
- data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
- data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
- data/lib/goodsorcery/backtrace/code_context.rb +27 -0
- data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
- data/lib/goodsorcery/backtrace/parser.rb +38 -0
- data/lib/goodsorcery/backtrace/paths.rb +42 -0
- data/lib/goodsorcery/bundler_plugin.rb +117 -0
- data/lib/goodsorcery/client.rb +180 -0
- data/lib/goodsorcery/configuration/validation.rb +59 -0
- data/lib/goodsorcery/configuration.rb +204 -0
- data/lib/goodsorcery/defaults.rb +40 -0
- data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
- data/lib/goodsorcery/delivery/async.rb +122 -0
- data/lib/goodsorcery/delivery/backoff.rb +20 -0
- data/lib/goodsorcery/delivery/custom.rb +27 -0
- data/lib/goodsorcery/delivery/event_queue.rb +124 -0
- data/lib/goodsorcery/delivery/sync.rb +27 -0
- data/lib/goodsorcery/delivery/test.rb +30 -0
- data/lib/goodsorcery/delivery/worker.rb +71 -0
- data/lib/goodsorcery/delivery.rb +39 -0
- data/lib/goodsorcery/delivery_job.rb +26 -0
- data/lib/goodsorcery/env_flag.rb +24 -0
- data/lib/goodsorcery/environment_gate.rb +61 -0
- data/lib/goodsorcery/errors.rb +14 -0
- data/lib/goodsorcery/event/breadcrumb.rb +32 -0
- data/lib/goodsorcery/event/builder.rb +77 -0
- data/lib/goodsorcery/event/cause_chain.rb +39 -0
- data/lib/goodsorcery/event/exception_section.rb +68 -0
- data/lib/goodsorcery/event/fields.rb +61 -0
- data/lib/goodsorcery/event/job_section.rb +34 -0
- data/lib/goodsorcery/event/request_section.rb +41 -0
- data/lib/goodsorcery/event/runtime_context.rb +52 -0
- data/lib/goodsorcery/event/tag_set.rb +41 -0
- data/lib/goodsorcery/event/user_section.rb +36 -0
- data/lib/goodsorcery/event_filter.rb +54 -0
- data/lib/goodsorcery/event_pipeline.rb +35 -0
- data/lib/goodsorcery/event_serializer.rb +25 -0
- data/lib/goodsorcery/exception_coercion.rb +18 -0
- data/lib/goodsorcery/extra_filter_probe.rb +32 -0
- data/lib/goodsorcery/ignore_list.rb +15 -0
- data/lib/goodsorcery/install_check.rb +58 -0
- data/lib/goodsorcery/integrations/action_cable.rb +75 -0
- data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
- data/lib/goodsorcery/integrations/active_job.rb +39 -0
- data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
- data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
- data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
- data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
- data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
- data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
- data/lib/goodsorcery/integrations/rails.rb +56 -0
- data/lib/goodsorcery/integrations/rake.rb +68 -0
- data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
- data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
- data/lib/goodsorcery/integrations.rb +27 -0
- data/lib/goodsorcery/job_arguments.rb +77 -0
- data/lib/goodsorcery/key_filter.rb +99 -0
- data/lib/goodsorcery/log.rb +55 -0
- data/lib/goodsorcery/masked_key.rb +29 -0
- data/lib/goodsorcery/rack/middleware.rb +57 -0
- data/lib/goodsorcery/rack/request_details.rb +120 -0
- data/lib/goodsorcery/rack/request_enricher.rb +20 -0
- data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
- data/lib/goodsorcery/railtie.rb +29 -0
- data/lib/goodsorcery/release_detector.rb +42 -0
- data/lib/goodsorcery/reported_mark.rb +19 -0
- data/lib/goodsorcery/sanitizer.rb +71 -0
- data/lib/goodsorcery/scope.rb +106 -0
- data/lib/goodsorcery/status_report.rb +50 -0
- data/lib/goodsorcery/tasks.rb +46 -0
- data/lib/goodsorcery/test_event.rb +46 -0
- data/lib/goodsorcery/text.rb +35 -0
- data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
- data/lib/goodsorcery/transport/connection.rb +87 -0
- data/lib/goodsorcery/transport/result.rb +103 -0
- data/lib/goodsorcery/transport.rb +172 -0
- data/lib/goodsorcery/verification.rb +47 -0
- data/lib/goodsorcery/version.rb +6 -0
- data/lib/goodsorcery.rb +176 -0
- data/plugins.rb +6 -0
- metadata +130 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Integrations
|
|
5
|
+
module Sidekiq
|
|
6
|
+
# The "job" section, transaction and queue tag for one Sidekiq job,
|
|
7
|
+
# read from the job hash when an error is reported. Arguments are left
|
|
8
|
+
# out when send_job_arguments is false or the job class has
|
|
9
|
+
# `sidekiq_options goodsorcery_arguments: false`.
|
|
10
|
+
class JobDetails
|
|
11
|
+
def initialize(job, queue: nil, worker: nil)
|
|
12
|
+
@job = job
|
|
13
|
+
@queue = queue || job["queue"]
|
|
14
|
+
@worker = worker
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def open(scope)
|
|
18
|
+
scope.transaction = job_class
|
|
19
|
+
scope.merge_tags("queue" => @queue) if @queue
|
|
20
|
+
scope.enricher = self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enrich(scope)
|
|
24
|
+
scope.job = to_h
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
section = {
|
|
29
|
+
"framework" => "sidekiq", "class" => job_class, "id" => @job["jid"], "queue" => @queue,
|
|
30
|
+
"retry_count" => @job["retry_count"], "enqueued_at" => Event::Fields.iso_time(@job["enqueued_at"])
|
|
31
|
+
}
|
|
32
|
+
section["arguments"] = JobArguments.new.serialize(Array(@job["args"])) if send_arguments?
|
|
33
|
+
section.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def job_class
|
|
39
|
+
@job["class"].to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def send_arguments?
|
|
43
|
+
GoodSorcery.configuration.send_job_arguments && @job["goodsorcery_arguments"] != false &&
|
|
44
|
+
class_option != false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def class_option
|
|
48
|
+
klass = @worker&.class
|
|
49
|
+
klass.respond_to?(:get_sidekiq_options) ? klass.get_sidekiq_options["goodsorcery_arguments"] : nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integrations"
|
|
4
|
+
require_relative "sidekiq/job_details"
|
|
5
|
+
|
|
6
|
+
module GoodSorcery
|
|
7
|
+
module Integrations
|
|
8
|
+
# Sidekiq (DESIGN.md 5.1), without depending on the gem: a server
|
|
9
|
+
# middleware opens a scope per job and reports what escapes it, and an
|
|
10
|
+
# error handler catches anything raised outside the middleware. Each
|
|
11
|
+
# exception object is reported once. Jobs wrapped by Active Job are left
|
|
12
|
+
# to the Active Job layer.
|
|
13
|
+
module Sidekiq
|
|
14
|
+
def self.install
|
|
15
|
+
return if @installed
|
|
16
|
+
|
|
17
|
+
@installed = true
|
|
18
|
+
::Sidekiq.configure_server do |config|
|
|
19
|
+
config.server_middleware { |chain| chain.add(ServerMiddleware) }
|
|
20
|
+
config.error_handlers << ErrorHandler.new.method(:report)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.active_job_wrapper?(job)
|
|
25
|
+
job.is_a?(Hash) && !job["wrapped"].nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sidekiq server middleware.
|
|
29
|
+
class ServerMiddleware
|
|
30
|
+
attr_accessor :config
|
|
31
|
+
|
|
32
|
+
def call(worker, job, queue, &)
|
|
33
|
+
return yield if Sidekiq.active_job_wrapper?(job)
|
|
34
|
+
|
|
35
|
+
Integrations.run_unit_of_work(JobDetails.new(job, queue: queue, worker: worker), &)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Sidekiq error handler, registered as its #report method: Sidekiq
|
|
40
|
+
# passes (exception, context) before Sidekiq 7, (exception, context,
|
|
41
|
+
# config) since.
|
|
42
|
+
class ErrorHandler
|
|
43
|
+
def report(exception, context, _config = nil)
|
|
44
|
+
return if ReportedMark.reported?(exception)
|
|
45
|
+
|
|
46
|
+
job = context.is_a?(Hash) ? context[:job] : nil
|
|
47
|
+
GoodSorcery.with_scope do |scope|
|
|
48
|
+
JobDetails.new(job).open(scope) if job.is_a?(Hash) && !Sidekiq.active_job_wrapper?(job)
|
|
49
|
+
GoodSorcery.notify(exception)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Framework integrations (DESIGN.md 5). Each layer opens a fresh scope per
|
|
5
|
+
# unit of work and leaves an enricher that reads its details only when an
|
|
6
|
+
# error is reported.
|
|
7
|
+
module Integrations
|
|
8
|
+
# GoodSorcery::DeliveryJob sends events; errors it raises are never
|
|
9
|
+
# reported (no recursion).
|
|
10
|
+
def self.delivery_job?(object)
|
|
11
|
+
GoodSorcery.const_defined?(:DeliveryJob, false) && object.is_a?(GoodSorcery::DeliveryJob)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Runs the block in a fresh scope prepared by +details+ (an object with
|
|
15
|
+
# +open(scope)+), reporting any exception that escapes it, then
|
|
16
|
+
# re-raising.
|
|
17
|
+
def self.run_unit_of_work(details)
|
|
18
|
+
GoodSorcery.with_scope do |scope|
|
|
19
|
+
details.open(scope)
|
|
20
|
+
yield
|
|
21
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
22
|
+
GoodSorcery.notify(e)
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Turns job arguments (and other app values headed for an event) into JSON
|
|
5
|
+
# data the way a job system would see them, without loading anything or
|
|
6
|
+
# calling #inspect: GlobalID objects become their gid string, hash keys are
|
|
7
|
+
# kept (so key filtering applies), symbols become strings, times ISO 8601,
|
|
8
|
+
# and any other object a "#<ClassName>" placeholder. Depth, collection sizes
|
|
9
|
+
# and string lengths are capped like request params.
|
|
10
|
+
class JobArguments
|
|
11
|
+
MAX_DEPTH = 6
|
|
12
|
+
MAX_ITEMS = 50
|
|
13
|
+
MAX_KEYS = 100
|
|
14
|
+
MAX_STRING = 4_096
|
|
15
|
+
MAX_KEY_LENGTH = 200
|
|
16
|
+
|
|
17
|
+
def serialize(value, depth = 1)
|
|
18
|
+
case value
|
|
19
|
+
when Hash then serialize_hash(value, depth)
|
|
20
|
+
when Array then serialize_array(value, depth)
|
|
21
|
+
when String then Text.limit(value, MAX_STRING)
|
|
22
|
+
when Symbol then Text.limit(value.name, MAX_STRING)
|
|
23
|
+
when Integer, true, false, nil then value
|
|
24
|
+
else serialize_object(value)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def serialize_hash(hash, depth)
|
|
31
|
+
return Sanitizer::TRUNCATED if depth > MAX_DEPTH
|
|
32
|
+
|
|
33
|
+
hash.first(MAX_KEYS).to_h do |key, item|
|
|
34
|
+
[Text.limit(key_name(key), MAX_KEY_LENGTH), serialize(item, depth + 1)]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def serialize_array(array, depth)
|
|
39
|
+
return Sanitizer::TRUNCATED if depth > MAX_DEPTH
|
|
40
|
+
|
|
41
|
+
array.first(MAX_ITEMS).map { |item| serialize(item, depth + 1) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def key_name(key)
|
|
45
|
+
plain_key?(key) ? key.to_s : placeholder(key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def plain_key?(key)
|
|
49
|
+
key.is_a?(String) || key.is_a?(Symbol) || key.is_a?(Integer)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def serialize_object(value)
|
|
53
|
+
return value if value.is_a?(Float) && value.finite?
|
|
54
|
+
return value.to_s if value.is_a?(Numeric)
|
|
55
|
+
return value.name.to_s if value.is_a?(Module)
|
|
56
|
+
|
|
57
|
+
identify(value)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
placeholder(value)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Records as their GlobalID, times and dates as ISO 8601, anything else
|
|
63
|
+
# as a placeholder.
|
|
64
|
+
def identify(value)
|
|
65
|
+
return value.to_global_id.to_s if value.respond_to?(:to_global_id)
|
|
66
|
+
return Text.limit(value.iso8601, 64) if value.respond_to?(:iso8601)
|
|
67
|
+
|
|
68
|
+
placeholder(value)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def placeholder(value)
|
|
72
|
+
"#<#{value.class.name || "Object"}>"
|
|
73
|
+
rescue StandardError
|
|
74
|
+
"#<Object>"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Masks values under sensitive keys, at any depth.
|
|
5
|
+
#
|
|
6
|
+
# Strings and symbols match a whole key, case-insensitively, with "-" and
|
|
7
|
+
# "_" treated as equal ("secret_key" matches "Secret-Key" but not
|
|
8
|
+
# "my_secret_key"). Regexps match any part of a key, in its own spelling
|
|
9
|
+
# or with "-" read as "_" (so /_key/ catches the "X-Access-Key" header).
|
|
10
|
+
# Patterns are compiled once, when the filter is built.
|
|
11
|
+
class KeyFilter
|
|
12
|
+
FILTERED = "[FILTERED]"
|
|
13
|
+
MAX_DEPTH = 16
|
|
14
|
+
|
|
15
|
+
def initialize(patterns)
|
|
16
|
+
regexps, names = Array(patterns).partition { |pattern| pattern.is_a?(Regexp) }
|
|
17
|
+
@names = names.to_h { |name| [normalize(name), true] }
|
|
18
|
+
@pattern = regexps.empty? ? nil : Regexp.union(regexps)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def filter_key?(key)
|
|
22
|
+
name = key.to_s
|
|
23
|
+
@names.key?(normalize(name)) || pattern_match?(name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns a filtered copy of +value+ (hashes and arrays are rebuilt).
|
|
27
|
+
def filter(value, depth = 0)
|
|
28
|
+
case value
|
|
29
|
+
when Hash then filter_hash(value, depth)
|
|
30
|
+
when Array then value.map { |item| filter(item, depth + 1) }
|
|
31
|
+
else value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Masks sensitive parameters in a URL's query string ("user[password]=x"
|
|
36
|
+
# is matched on "password" as well). +extra+ (an ExtraFilterProbe) also
|
|
37
|
+
# applies the extra filters, such as Rails' filter_parameters.
|
|
38
|
+
def filter_url(url, extra = nil)
|
|
39
|
+
return url unless url.is_a?(String) && url.include?("?")
|
|
40
|
+
|
|
41
|
+
base, query = url.split("?", 2)
|
|
42
|
+
query, fragment = query.split("#", 2)
|
|
43
|
+
filtered = "#{base}?#{filter_query(query, extra)}"
|
|
44
|
+
fragment ? "#{filtered}##{fragment}" : filtered
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def filter_hash(hash, depth)
|
|
50
|
+
return Sanitizer::TRUNCATED if depth > MAX_DEPTH
|
|
51
|
+
|
|
52
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
53
|
+
filter_entry(out, key, value, depth)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def filter_entry(out, key, value, depth)
|
|
58
|
+
return out[key] = filter(value, depth + 1) unless filter_key?(key)
|
|
59
|
+
|
|
60
|
+
out[key] = FILTERED
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def filter_query(query, extra)
|
|
64
|
+
query.split("&").map { |pair| filter_query_pair(pair, extra) }.join("&")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def filter_query_pair(pair, extra)
|
|
68
|
+
key = pair.split("=", 2).first.to_s
|
|
69
|
+
return pair unless sensitive_query_key?(key, extra)
|
|
70
|
+
|
|
71
|
+
"#{key}=#{FILTERED}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sensitive_query_key?(key, extra)
|
|
75
|
+
decoded = decode(key)
|
|
76
|
+
parts = decoded.scan(/[^\[\]]+/)
|
|
77
|
+
return true if [decoded, *parts].any? { |name| filter_key?(name) }
|
|
78
|
+
|
|
79
|
+
extra ? extra.filters_path?(parts) : false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def decode(key)
|
|
83
|
+
URI.decode_www_form_component(key)
|
|
84
|
+
rescue ArgumentError
|
|
85
|
+
key
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def pattern_match?(name)
|
|
89
|
+
return false unless @pattern
|
|
90
|
+
return true if @pattern.match?(name)
|
|
91
|
+
|
|
92
|
+
name.include?("-") && @pattern.match?(name.tr("-", "_"))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize(name)
|
|
96
|
+
name.to_s.downcase.tr("-", "_")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module GoodSorcery
|
|
6
|
+
# Prefixes and rate-limits the gem's own log lines. Never pass captured
|
|
7
|
+
# error data or API keys to it: messages name classes, counts and statuses.
|
|
8
|
+
class Log
|
|
9
|
+
PREFIX = "[goodsorcery]"
|
|
10
|
+
|
|
11
|
+
def initialize(&logger_source)
|
|
12
|
+
@logger_source = logger_source
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@last_logged = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def debug(message) = write(:debug, message)
|
|
18
|
+
def info(message) = write(:info, message)
|
|
19
|
+
def warn(message) = write(:warn, message)
|
|
20
|
+
def error(message) = write(:error, message)
|
|
21
|
+
|
|
22
|
+
# Logs +message+ only the first time +key+ is seen in this process.
|
|
23
|
+
def warn_once(key, message)
|
|
24
|
+
warn(message) if first_time?(key, Float::INFINITY)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Logs +message+ at most once per +interval+ seconds for +key+.
|
|
28
|
+
def warn_every(key, interval, message)
|
|
29
|
+
warn(message) if first_time?(key, interval)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def forget(key)
|
|
33
|
+
@mutex.synchronize { @last_logged.delete(key) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def first_time?(key, interval)
|
|
39
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
last = @last_logged[key]
|
|
42
|
+
next false if last && (interval == Float::INFINITY || now - last < interval)
|
|
43
|
+
|
|
44
|
+
@last_logged[key] = now
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def write(level, message)
|
|
50
|
+
@logger_source.call&.public_send(level, "#{PREFIX} #{message}")
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Shows an API key without revealing it: "gs_test_9f2c…e81a" becomes "gs_test_…e81a".
|
|
5
|
+
class MaskedKey
|
|
6
|
+
PREFIX = /\A(gs_[a-z]+_)/
|
|
7
|
+
MIN_LENGTH_FOR_TAIL = 16
|
|
8
|
+
|
|
9
|
+
def initialize(key)
|
|
10
|
+
@key = key.to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_s
|
|
14
|
+
return "(not set)" if @key.empty?
|
|
15
|
+
|
|
16
|
+
"#{prefix}…#{tail}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def prefix
|
|
22
|
+
@key[PREFIX, 1].to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def tail
|
|
26
|
+
@key.length >= MIN_LENGTH_FOR_TAIL ? @key[-4..] : ""
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "request_details"
|
|
4
|
+
require_relative "request_enricher"
|
|
5
|
+
|
|
6
|
+
module GoodSorcery
|
|
7
|
+
module Rack
|
|
8
|
+
# Rack middleware for Rails, Sinatra and plain Rack apps. Opens a fresh
|
|
9
|
+
# scope per request (cleared afterwards, whatever happens), records a
|
|
10
|
+
# "METHOD /path" breadcrumb and reports exceptions that propagate, then
|
|
11
|
+
# re-raises them. Exceptions an inner layer turned into a 5xx response
|
|
12
|
+
# (env["action_dispatch.exception"], env["sinatra.error"]) are reported
|
|
13
|
+
# too. The request itself is only read if an error is reported.
|
|
14
|
+
#
|
|
15
|
+
# use GoodSorcery::Rack::Middleware
|
|
16
|
+
class Middleware
|
|
17
|
+
RESCUED_ERROR_KEYS = %w[action_dispatch.exception sinatra.error].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(app, enricher: RequestEnricher)
|
|
20
|
+
@app = app
|
|
21
|
+
@enricher = enricher
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(env)
|
|
25
|
+
GoodSorcery.with_scope do |scope|
|
|
26
|
+
open_request(scope, env)
|
|
27
|
+
response = call_app(env)
|
|
28
|
+
report_rescued_error(env, response)
|
|
29
|
+
response
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def open_request(scope, env)
|
|
36
|
+
scope.request_env = env
|
|
37
|
+
scope.enricher = @enricher.new(env)
|
|
38
|
+
path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
|
|
39
|
+
GoodSorcery.add_breadcrumb("#{env["REQUEST_METHOD"]} #{path}", category: "request")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call_app(env)
|
|
43
|
+
@app.call(env)
|
|
44
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
45
|
+
GoodSorcery.notify(e)
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def report_rescued_error(env, response)
|
|
50
|
+
return unless response.is_a?(Array) && response.first.to_i >= 500
|
|
51
|
+
|
|
52
|
+
error = RESCUED_ERROR_KEYS.lazy.map { |key| env[key] }.find { |value| value.is_a?(Exception) }
|
|
53
|
+
GoodSorcery.notify(error) if error
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Rack
|
|
5
|
+
# Reads the "request" section from a Rack env, only when an error is
|
|
6
|
+
# reported. Uses what Rails (or Rack) already parsed and never reads the
|
|
7
|
+
# request body or loads the session. Filtering happens later, in
|
|
8
|
+
# EventFilter, like for every other section.
|
|
9
|
+
class RequestDetails
|
|
10
|
+
HEADER_PREFIX = "HTTP_"
|
|
11
|
+
DEFAULT_PORTS = { "http" => ":80", "https" => ":443" }.freeze
|
|
12
|
+
CONTENT_HEADERS = { "CONTENT_TYPE" => "Content-Type", "CONTENT_LENGTH" => "Content-Length" }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(env, config)
|
|
15
|
+
@env = env
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
{
|
|
21
|
+
"url" => url, "method" => @env["REQUEST_METHOD"], "format" => format,
|
|
22
|
+
"request_id" => request_id, "remote_ip" => remote_ip,
|
|
23
|
+
"params" => params, "headers" => headers, "session" => session
|
|
24
|
+
}.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def url
|
|
28
|
+
scheme = @env["rack.url_scheme"] || "http"
|
|
29
|
+
query = @env["QUERY_STRING"].to_s
|
|
30
|
+
base = "#{scheme}://#{host(scheme)}#{@env["SCRIPT_NAME"]}#{@env["PATH_INFO"]}"
|
|
31
|
+
query.empty? ? base : "#{base}?#{query}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def request_id
|
|
35
|
+
@env["action_dispatch.request_id"] || @env["HTTP_X_REQUEST_ID"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remote_ip
|
|
39
|
+
rails_ip = @env["action_dispatch.remote_ip"]
|
|
40
|
+
(rails_ip && Text.safe_to_s(rails_ip)) || @env["REMOTE_ADDR"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def headers
|
|
44
|
+
return nil unless @config.send_request_headers
|
|
45
|
+
|
|
46
|
+
@env.each_with_object({}) do |(name, value), headers|
|
|
47
|
+
header = header_name(name)
|
|
48
|
+
headers[header] = value if header && value.is_a?(String)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def params
|
|
53
|
+
return nil unless @config.send_request_params
|
|
54
|
+
|
|
55
|
+
parsed = @env["action_dispatch.request.parameters"]
|
|
56
|
+
parsed.is_a?(Hash) ? parsed : RackParams.new(@env).to_h
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def session
|
|
60
|
+
return nil unless @config.send_session
|
|
61
|
+
|
|
62
|
+
session = @env["rack.session"]
|
|
63
|
+
return session if session.is_a?(Hash)
|
|
64
|
+
return nil unless session.respond_to?(:loaded?) && session.loaded?
|
|
65
|
+
|
|
66
|
+
session.to_hash
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def host(scheme)
|
|
74
|
+
host = @env["HTTP_HOST"] || [@env["SERVER_NAME"], @env["SERVER_PORT"]].compact.join(":")
|
|
75
|
+
host.delete_suffix(DEFAULT_PORTS.fetch(scheme, ""))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def format
|
|
79
|
+
formats = @env["action_dispatch.request.formats"]
|
|
80
|
+
formats.is_a?(Array) && formats.first ? Text.safe_to_s(formats.first) : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def header_name(name)
|
|
84
|
+
return CONTENT_HEADERS[name] unless name.start_with?(HEADER_PREFIX)
|
|
85
|
+
|
|
86
|
+
name.delete_prefix(HEADER_PREFIX).split("_").map(&:capitalize).join("-")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Params a plain Rack app already has: the parsed query string, plus the
|
|
91
|
+
# form body only if the app parsed it already (Rack caches it in the env).
|
|
92
|
+
# Path params from the Rails router are added when present.
|
|
93
|
+
class RackParams
|
|
94
|
+
def initialize(env)
|
|
95
|
+
@env = env
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def to_h
|
|
99
|
+
merged = query.merge(cached_form)
|
|
100
|
+
path = @env["action_dispatch.request.path_parameters"]
|
|
101
|
+
path.is_a?(Hash) ? merged.merge(path.transform_keys(&:to_s)) : merged
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def query
|
|
109
|
+
return {} unless defined?(::Rack::Request)
|
|
110
|
+
|
|
111
|
+
::Rack::Request.new(@env).GET
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cached_form
|
|
115
|
+
form = @env["rack.request.form_hash"]
|
|
116
|
+
form.is_a?(Hash) ? form : {}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Rack
|
|
5
|
+
# Fills in the request section when an error is reported during a Rack
|
|
6
|
+
# request. Leaves it to config.request_extractor when one is configured.
|
|
7
|
+
class RequestEnricher
|
|
8
|
+
def initialize(env)
|
|
9
|
+
@env = env
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def enrich(scope)
|
|
13
|
+
config = GoodSorcery.configuration
|
|
14
|
+
return if config.request_extractor || scope.request
|
|
15
|
+
|
|
16
|
+
scope.request = RequestDetails.new(@env, config).to_h
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Rails.application.config.filter_parameters as an extra filter, using
|
|
5
|
+
# ActiveSupport::ParameterFilter so Rails' own matching rules apply. Built
|
|
6
|
+
# whenever the event pipeline is compiled; nil outside a Rails app.
|
|
7
|
+
module RailsFilterParameters
|
|
8
|
+
def self.build
|
|
9
|
+
filters = rails_filters
|
|
10
|
+
return nil if filters.nil? || filters.empty?
|
|
11
|
+
|
|
12
|
+
::ActiveSupport::ParameterFilter.new(filters, mask: KeyFilter::FILTERED)
|
|
13
|
+
rescue StandardError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.rails_filters
|
|
18
|
+
return nil unless defined?(::ActiveSupport::ParameterFilter) && defined?(::Rails)
|
|
19
|
+
return nil unless ::Rails.respond_to?(:application) && ::Rails.application
|
|
20
|
+
|
|
21
|
+
::Rails.application.config.filter_parameters
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../goodsorcery" unless defined?(GoodSorcery::Client)
|
|
4
|
+
require_relative "integrations/rails"
|
|
5
|
+
|
|
6
|
+
module GoodSorcery
|
|
7
|
+
# Rails integration (DESIGN.md 5): defaults from Rails (root, environment,
|
|
8
|
+
# logger, filter_parameters and the REVISION file are read lazily by
|
|
9
|
+
# Configuration), the Rack middleware after ActionDispatch::ShowExceptions,
|
|
10
|
+
# the Rails.error subscriber, the Active Job, Action Cable and notification
|
|
11
|
+
# breadcrumb layers, rake tasks and the goodsorcery:install generator.
|
|
12
|
+
class Railtie < ::Rails::Railtie
|
|
13
|
+
initializer "goodsorcery.middleware" do |app|
|
|
14
|
+
GoodSorcery::Integrations::Rails.install_middleware(app.config.middleware)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "goodsorcery.framework_hooks" do
|
|
18
|
+
GoodSorcery::Integrations::Rails.install_framework_hooks
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config.after_initialize do
|
|
22
|
+
GoodSorcery::Integrations::Rails.after_initialize
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
rake_tasks do
|
|
26
|
+
require_relative "tasks"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
# Finds the running release without shelling out to git: the first present
|
|
5
|
+
# deploy environment variable, else the Capistrano REVISION file.
|
|
6
|
+
class ReleaseDetector
|
|
7
|
+
ENV_NAMES = %w[
|
|
8
|
+
GOODSORCERY_RELEASE KAMAL_VERSION HEROKU_SLUG_COMMIT HEROKU_BUILD_COMMIT
|
|
9
|
+
RENDER_GIT_COMMIT GIT_COMMIT GIT_SHA SOURCE_VERSION
|
|
10
|
+
].freeze
|
|
11
|
+
MAX_LENGTH = 64
|
|
12
|
+
|
|
13
|
+
def initialize(root)
|
|
14
|
+
@root = root
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def release
|
|
18
|
+
value = release_from_env || release_from_revision_file
|
|
19
|
+
value&.strip&.slice(0, MAX_LENGTH)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def release_from_env
|
|
25
|
+
ENV_NAMES.each do |name|
|
|
26
|
+
value = EnvFlag.present(name)
|
|
27
|
+
return value if value
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def release_from_revision_file
|
|
33
|
+
return unless @root
|
|
34
|
+
|
|
35
|
+
path = File.join(@root.to_s, "REVISION")
|
|
36
|
+
content = File.file?(path) ? File.read(path, 256).to_s.strip : ""
|
|
37
|
+
content.empty? ? nil : content
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|