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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/LICENSE +21 -0
  4. data/README.md +299 -0
  5. data/lib/generators/goodsorcery/install/credentials_api_key.rb +64 -0
  6. data/lib/generators/goodsorcery/install/install_generator.rb +64 -0
  7. data/lib/generators/goodsorcery/install/templates/initializer.rb +54 -0
  8. data/lib/goodsorcery/backtrace/code_context.rb +27 -0
  9. data/lib/goodsorcery/backtrace/line_cache.rb +57 -0
  10. data/lib/goodsorcery/backtrace/parser.rb +38 -0
  11. data/lib/goodsorcery/backtrace/paths.rb +42 -0
  12. data/lib/goodsorcery/bundler_plugin.rb +117 -0
  13. data/lib/goodsorcery/client.rb +180 -0
  14. data/lib/goodsorcery/configuration/validation.rb +59 -0
  15. data/lib/goodsorcery/configuration.rb +204 -0
  16. data/lib/goodsorcery/defaults.rb +40 -0
  17. data/lib/goodsorcery/delivery/active_job_queue.rb +71 -0
  18. data/lib/goodsorcery/delivery/async.rb +122 -0
  19. data/lib/goodsorcery/delivery/backoff.rb +20 -0
  20. data/lib/goodsorcery/delivery/custom.rb +27 -0
  21. data/lib/goodsorcery/delivery/event_queue.rb +124 -0
  22. data/lib/goodsorcery/delivery/sync.rb +27 -0
  23. data/lib/goodsorcery/delivery/test.rb +30 -0
  24. data/lib/goodsorcery/delivery/worker.rb +71 -0
  25. data/lib/goodsorcery/delivery.rb +39 -0
  26. data/lib/goodsorcery/delivery_job.rb +26 -0
  27. data/lib/goodsorcery/env_flag.rb +24 -0
  28. data/lib/goodsorcery/environment_gate.rb +61 -0
  29. data/lib/goodsorcery/errors.rb +14 -0
  30. data/lib/goodsorcery/event/breadcrumb.rb +32 -0
  31. data/lib/goodsorcery/event/builder.rb +77 -0
  32. data/lib/goodsorcery/event/cause_chain.rb +39 -0
  33. data/lib/goodsorcery/event/exception_section.rb +68 -0
  34. data/lib/goodsorcery/event/fields.rb +61 -0
  35. data/lib/goodsorcery/event/job_section.rb +34 -0
  36. data/lib/goodsorcery/event/request_section.rb +41 -0
  37. data/lib/goodsorcery/event/runtime_context.rb +52 -0
  38. data/lib/goodsorcery/event/tag_set.rb +41 -0
  39. data/lib/goodsorcery/event/user_section.rb +36 -0
  40. data/lib/goodsorcery/event_filter.rb +54 -0
  41. data/lib/goodsorcery/event_pipeline.rb +35 -0
  42. data/lib/goodsorcery/event_serializer.rb +25 -0
  43. data/lib/goodsorcery/exception_coercion.rb +18 -0
  44. data/lib/goodsorcery/extra_filter_probe.rb +32 -0
  45. data/lib/goodsorcery/ignore_list.rb +15 -0
  46. data/lib/goodsorcery/install_check.rb +58 -0
  47. data/lib/goodsorcery/integrations/action_cable.rb +75 -0
  48. data/lib/goodsorcery/integrations/active_job/job_details.rb +90 -0
  49. data/lib/goodsorcery/integrations/active_job.rb +39 -0
  50. data/lib/goodsorcery/integrations/rails/controller_action.rb +40 -0
  51. data/lib/goodsorcery/integrations/rails/controller_enricher.rb +27 -0
  52. data/lib/goodsorcery/integrations/rails/error_subscriber.rb +40 -0
  53. data/lib/goodsorcery/integrations/rails/notification_breadcrumbs.rb +82 -0
  54. data/lib/goodsorcery/integrations/rails/sql_statement.rb +31 -0
  55. data/lib/goodsorcery/integrations/rails/user_lookup.rb +39 -0
  56. data/lib/goodsorcery/integrations/rails.rb +56 -0
  57. data/lib/goodsorcery/integrations/rake.rb +68 -0
  58. data/lib/goodsorcery/integrations/sidekiq/job_details.rb +54 -0
  59. data/lib/goodsorcery/integrations/sidekiq.rb +55 -0
  60. data/lib/goodsorcery/integrations.rb +27 -0
  61. data/lib/goodsorcery/job_arguments.rb +77 -0
  62. data/lib/goodsorcery/key_filter.rb +99 -0
  63. data/lib/goodsorcery/log.rb +55 -0
  64. data/lib/goodsorcery/masked_key.rb +29 -0
  65. data/lib/goodsorcery/rack/middleware.rb +57 -0
  66. data/lib/goodsorcery/rack/request_details.rb +120 -0
  67. data/lib/goodsorcery/rack/request_enricher.rb +20 -0
  68. data/lib/goodsorcery/rails_filter_parameters.rb +24 -0
  69. data/lib/goodsorcery/railtie.rb +29 -0
  70. data/lib/goodsorcery/release_detector.rb +42 -0
  71. data/lib/goodsorcery/reported_mark.rb +19 -0
  72. data/lib/goodsorcery/sanitizer.rb +71 -0
  73. data/lib/goodsorcery/scope.rb +106 -0
  74. data/lib/goodsorcery/status_report.rb +50 -0
  75. data/lib/goodsorcery/tasks.rb +46 -0
  76. data/lib/goodsorcery/test_event.rb +46 -0
  77. data/lib/goodsorcery/text.rb +35 -0
  78. data/lib/goodsorcery/transport/batch_chunks.rb +40 -0
  79. data/lib/goodsorcery/transport/connection.rb +87 -0
  80. data/lib/goodsorcery/transport/result.rb +103 -0
  81. data/lib/goodsorcery/transport.rb +172 -0
  82. data/lib/goodsorcery/verification.rb +47 -0
  83. data/lib/goodsorcery/version.rb +6 -0
  84. data/lib/goodsorcery.rb +176 -0
  85. data/plugins.rb +6 -0
  86. 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