sentry-ruby 5.1.0 → 5.4.2

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +11 -0
  3. data/.rspec +3 -0
  4. data/.yardopts +2 -0
  5. data/CHANGELOG.md +313 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +27 -0
  8. data/Makefile +4 -0
  9. data/README.md +8 -7
  10. data/Rakefile +13 -0
  11. data/bin/console +18 -0
  12. data/bin/setup +8 -0
  13. data/lib/sentry/background_worker.rb +72 -0
  14. data/lib/sentry/backtrace.rb +124 -0
  15. data/lib/sentry/breadcrumb/sentry_logger.rb +90 -0
  16. data/lib/sentry/breadcrumb.rb +70 -0
  17. data/lib/sentry/breadcrumb_buffer.rb +64 -0
  18. data/lib/sentry/client.rb +190 -0
  19. data/lib/sentry/configuration.rb +502 -0
  20. data/lib/sentry/core_ext/object/deep_dup.rb +61 -0
  21. data/lib/sentry/core_ext/object/duplicable.rb +155 -0
  22. data/lib/sentry/dsn.rb +53 -0
  23. data/lib/sentry/envelope.rb +96 -0
  24. data/lib/sentry/error_event.rb +38 -0
  25. data/lib/sentry/event.rb +178 -0
  26. data/lib/sentry/exceptions.rb +9 -0
  27. data/lib/sentry/hub.rb +220 -0
  28. data/lib/sentry/integrable.rb +26 -0
  29. data/lib/sentry/interface.rb +16 -0
  30. data/lib/sentry/interfaces/exception.rb +43 -0
  31. data/lib/sentry/interfaces/request.rb +144 -0
  32. data/lib/sentry/interfaces/single_exception.rb +57 -0
  33. data/lib/sentry/interfaces/stacktrace.rb +87 -0
  34. data/lib/sentry/interfaces/stacktrace_builder.rb +79 -0
  35. data/lib/sentry/interfaces/threads.rb +42 -0
  36. data/lib/sentry/linecache.rb +47 -0
  37. data/lib/sentry/logger.rb +20 -0
  38. data/lib/sentry/net/http.rb +115 -0
  39. data/lib/sentry/rack/capture_exceptions.rb +80 -0
  40. data/lib/sentry/rack.rb +5 -0
  41. data/lib/sentry/rake.rb +41 -0
  42. data/lib/sentry/redis.rb +90 -0
  43. data/lib/sentry/release_detector.rb +39 -0
  44. data/lib/sentry/scope.rb +295 -0
  45. data/lib/sentry/session.rb +35 -0
  46. data/lib/sentry/session_flusher.rb +90 -0
  47. data/lib/sentry/span.rb +226 -0
  48. data/lib/sentry/test_helper.rb +76 -0
  49. data/lib/sentry/transaction.rb +206 -0
  50. data/lib/sentry/transaction_event.rb +29 -0
  51. data/lib/sentry/transport/configuration.rb +25 -0
  52. data/lib/sentry/transport/dummy_transport.rb +21 -0
  53. data/lib/sentry/transport/http_transport.rb +175 -0
  54. data/lib/sentry/transport.rb +210 -0
  55. data/lib/sentry/utils/argument_checking_helper.rb +13 -0
  56. data/lib/sentry/utils/custom_inspection.rb +14 -0
  57. data/lib/sentry/utils/exception_cause_chain.rb +20 -0
  58. data/lib/sentry/utils/logging_helper.rb +26 -0
  59. data/lib/sentry/utils/real_ip.rb +84 -0
  60. data/lib/sentry/utils/request_id.rb +18 -0
  61. data/lib/sentry/version.rb +5 -0
  62. data/lib/sentry-ruby.rb +505 -0
  63. data/sentry-ruby-core.gemspec +23 -0
  64. data/sentry-ruby.gemspec +24 -0
  65. metadata +64 -16
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "zlib"
5
+
6
+ module Sentry
7
+ class HTTPTransport < Transport
8
+ GZIP_ENCODING = "gzip"
9
+ GZIP_THRESHOLD = 1024 * 30
10
+ CONTENT_TYPE = 'application/x-sentry-envelope'
11
+
12
+ DEFAULT_DELAY = 60
13
+ RETRY_AFTER_HEADER = "retry-after"
14
+ RATE_LIMIT_HEADER = "x-sentry-rate-limits"
15
+ USER_AGENT = "sentry-ruby/#{Sentry::VERSION}"
16
+
17
+ def initialize(*args)
18
+ super
19
+ @endpoint = @dsn.envelope_endpoint
20
+
21
+ log_debug("Sentry HTTP Transport will connect to #{@dsn.server}")
22
+ end
23
+
24
+ def send_data(data)
25
+ encoding = ""
26
+
27
+ if should_compress?(data)
28
+ data = Zlib.gzip(data)
29
+ encoding = GZIP_ENCODING
30
+ end
31
+
32
+ headers = {
33
+ 'Content-Type' => CONTENT_TYPE,
34
+ 'Content-Encoding' => encoding,
35
+ 'X-Sentry-Auth' => generate_auth_header,
36
+ 'User-Agent' => USER_AGENT
37
+ }
38
+
39
+ response = conn.start do |http|
40
+ request = ::Net::HTTP::Post.new(@endpoint, headers)
41
+ request.body = data
42
+ http.request(request)
43
+ end
44
+
45
+ if response.code.match?(/\A2\d{2}/)
46
+ if has_rate_limited_header?(response)
47
+ handle_rate_limited_response(response)
48
+ end
49
+ else
50
+ error_info = "the server responded with status #{response.code}"
51
+
52
+ if response.code == "429"
53
+ handle_rate_limited_response(response)
54
+ else
55
+ error_info += "\nbody: #{response.body}"
56
+ error_info += " Error in headers is: #{response['x-sentry-error']}" if response['x-sentry-error']
57
+ end
58
+
59
+ raise Sentry::ExternalError, error_info
60
+ end
61
+ rescue SocketError => e
62
+ raise Sentry::ExternalError.new(e.message)
63
+ end
64
+
65
+ private
66
+
67
+ def has_rate_limited_header?(headers)
68
+ headers[RETRY_AFTER_HEADER] || headers[RATE_LIMIT_HEADER]
69
+ end
70
+
71
+ def handle_rate_limited_response(headers)
72
+ rate_limits =
73
+ if rate_limits = headers[RATE_LIMIT_HEADER]
74
+ parse_rate_limit_header(rate_limits)
75
+ elsif retry_after = headers[RETRY_AFTER_HEADER]
76
+ # although Sentry doesn't send a date string back
77
+ # based on HTTP specification, this could be a date string (instead of an integer)
78
+ retry_after = retry_after.to_i
79
+ retry_after = DEFAULT_DELAY if retry_after == 0
80
+
81
+ { nil => Time.now + retry_after }
82
+ else
83
+ { nil => Time.now + DEFAULT_DELAY }
84
+ end
85
+
86
+ rate_limits.each do |category, limit|
87
+ if current_limit = @rate_limits[category]
88
+ if current_limit < limit
89
+ @rate_limits[category] = limit
90
+ end
91
+ else
92
+ @rate_limits[category] = limit
93
+ end
94
+ end
95
+ end
96
+
97
+ def parse_rate_limit_header(rate_limit_header)
98
+ time = Time.now
99
+
100
+ result = {}
101
+
102
+ limits = rate_limit_header.split(",")
103
+ limits.each do |limit|
104
+ next if limit.nil? || limit.empty?
105
+
106
+ begin
107
+ retry_after, categories = limit.strip.split(":").first(2)
108
+ retry_after = time + retry_after.to_i
109
+ categories = categories.split(";")
110
+
111
+ if categories.empty?
112
+ result[nil] = retry_after
113
+ else
114
+ categories.each do |category|
115
+ result[category] = retry_after
116
+ end
117
+ end
118
+ rescue StandardError
119
+ end
120
+ end
121
+
122
+ result
123
+ end
124
+
125
+ def should_compress?(data)
126
+ @transport_configuration.encoding == GZIP_ENCODING && data.bytesize >= GZIP_THRESHOLD
127
+ end
128
+
129
+ def conn
130
+ server = URI(@dsn.server)
131
+
132
+ connection =
133
+ if proxy = normalize_proxy(@transport_configuration.proxy)
134
+ ::Net::HTTP.new(server.hostname, server.port, proxy[:uri].hostname, proxy[:uri].port, proxy[:user], proxy[:password])
135
+ else
136
+ ::Net::HTTP.new(server.hostname, server.port, nil)
137
+ end
138
+
139
+ connection.use_ssl = server.scheme == "https"
140
+ connection.read_timeout = @transport_configuration.timeout
141
+ connection.write_timeout = @transport_configuration.timeout if connection.respond_to?(:write_timeout)
142
+ connection.open_timeout = @transport_configuration.open_timeout
143
+
144
+ ssl_configuration.each do |key, value|
145
+ connection.send("#{key}=", value)
146
+ end
147
+
148
+ connection
149
+ end
150
+
151
+ def normalize_proxy(proxy)
152
+ return proxy unless proxy
153
+
154
+ case proxy
155
+ when String
156
+ uri = URI(proxy)
157
+ { uri: uri, user: uri.user, password: uri.password }
158
+ when URI
159
+ { uri: proxy, user: proxy.user, password: proxy.password }
160
+ when Hash
161
+ proxy
162
+ end
163
+ end
164
+
165
+ def ssl_configuration
166
+ configuration = {
167
+ verify: @transport_configuration.ssl_verification,
168
+ ca_file: @transport_configuration.ssl_ca_file
169
+ }.merge(@transport_configuration.ssl || {})
170
+
171
+ configuration[:verify_mode] = configuration.delete(:verify) ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
172
+ configuration
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+ require "sentry/envelope"
6
+
7
+ module Sentry
8
+ class Transport
9
+ PROTOCOL_VERSION = '7'
10
+ USER_AGENT = "sentry-ruby/#{Sentry::VERSION}"
11
+ CLIENT_REPORT_INTERVAL = 30
12
+
13
+ # https://develop.sentry.dev/sdk/client-reports/#envelope-item-payload
14
+ CLIENT_REPORT_REASONS = [
15
+ :ratelimit_backoff,
16
+ :queue_overflow,
17
+ :cache_overflow, # NA
18
+ :network_error,
19
+ :sample_rate,
20
+ :before_send,
21
+ :event_processor
22
+ ]
23
+
24
+ include LoggingHelper
25
+
26
+ attr_reader :rate_limits, :discarded_events, :last_client_report_sent
27
+
28
+ # @deprecated Use Sentry.logger to retrieve the current logger instead.
29
+ attr_reader :logger
30
+
31
+ def initialize(configuration)
32
+ @logger = configuration.logger
33
+ @transport_configuration = configuration.transport
34
+ @dsn = configuration.dsn
35
+ @rate_limits = {}
36
+ @send_client_reports = configuration.send_client_reports
37
+
38
+ if @send_client_reports
39
+ @discarded_events = Hash.new(0)
40
+ @last_client_report_sent = Time.now
41
+ end
42
+ end
43
+
44
+ def send_data(data, options = {})
45
+ raise NotImplementedError
46
+ end
47
+
48
+ def send_event(event)
49
+ envelope = envelope_from_event(event)
50
+ send_envelope(envelope)
51
+
52
+ event
53
+ end
54
+
55
+ def send_envelope(envelope)
56
+ reject_rate_limited_items(envelope)
57
+
58
+ return if envelope.items.empty?
59
+
60
+ data, serialized_items = serialize_envelope(envelope)
61
+
62
+ if data
63
+ log_info("[Transport] Sending envelope with items [#{serialized_items.map(&:type).join(', ')}] #{envelope.event_id} to Sentry")
64
+ send_data(data)
65
+ end
66
+ end
67
+
68
+ def serialize_envelope(envelope)
69
+ serialized_items = []
70
+ serialized_results = []
71
+
72
+ envelope.items.each do |item|
73
+ result, oversized = item.serialize
74
+
75
+ if oversized
76
+ log_info("Envelope item [#{item.type}] is still oversized after size reduction: {#{item.size_breakdown}}")
77
+
78
+ next
79
+ end
80
+
81
+ serialized_results << result
82
+ serialized_items << item
83
+ end
84
+
85
+ data = [JSON.generate(envelope.headers), *serialized_results].join("\n") unless serialized_results.empty?
86
+
87
+ [data, serialized_items]
88
+ end
89
+
90
+ def is_rate_limited?(item_type)
91
+ # check category-specific limit
92
+ category_delay =
93
+ case item_type
94
+ when "transaction"
95
+ @rate_limits["transaction"]
96
+ when "sessions"
97
+ @rate_limits["session"]
98
+ else
99
+ @rate_limits["error"]
100
+ end
101
+
102
+ # check universal limit if not category limit
103
+ universal_delay = @rate_limits[nil]
104
+
105
+ delay =
106
+ if category_delay && universal_delay
107
+ if category_delay > universal_delay
108
+ category_delay
109
+ else
110
+ universal_delay
111
+ end
112
+ elsif category_delay
113
+ category_delay
114
+ else
115
+ universal_delay
116
+ end
117
+
118
+ !!delay && delay > Time.now
119
+ end
120
+
121
+ def generate_auth_header
122
+ now = Sentry.utc_now.to_i
123
+ fields = {
124
+ 'sentry_version' => PROTOCOL_VERSION,
125
+ 'sentry_client' => USER_AGENT,
126
+ 'sentry_timestamp' => now,
127
+ 'sentry_key' => @dsn.public_key
128
+ }
129
+ fields['sentry_secret'] = @dsn.secret_key if @dsn.secret_key
130
+ 'Sentry ' + fields.map { |key, value| "#{key}=#{value}" }.join(', ')
131
+ end
132
+
133
+ def envelope_from_event(event)
134
+ # Convert to hash
135
+ event_payload = event.to_hash
136
+ event_id = event_payload[:event_id] || event_payload["event_id"]
137
+ item_type = event_payload[:type] || event_payload["type"]
138
+
139
+ envelope = Envelope.new(
140
+ {
141
+ event_id: event_id,
142
+ dsn: @dsn.to_s,
143
+ sdk: Sentry.sdk_meta,
144
+ sent_at: Sentry.utc_now.iso8601
145
+ }
146
+ )
147
+
148
+ envelope.add_item(
149
+ { type: item_type, content_type: 'application/json' },
150
+ event_payload
151
+ )
152
+
153
+ client_report_headers, client_report_payload = fetch_pending_client_report
154
+ envelope.add_item(client_report_headers, client_report_payload) if client_report_headers
155
+
156
+ envelope
157
+ end
158
+
159
+ def record_lost_event(reason, item_type)
160
+ return unless @send_client_reports
161
+ return unless CLIENT_REPORT_REASONS.include?(reason)
162
+
163
+ @discarded_events[[reason, item_type]] += 1
164
+ end
165
+
166
+ private
167
+
168
+ def fetch_pending_client_report
169
+ return nil unless @send_client_reports
170
+ return nil if @last_client_report_sent > Time.now - CLIENT_REPORT_INTERVAL
171
+ return nil if @discarded_events.empty?
172
+
173
+ discarded_events_hash = @discarded_events.map do |key, val|
174
+ reason, type = key
175
+
176
+ # 'event' has to be mapped to 'error'
177
+ category = type == 'transaction' ? 'transaction' : 'error'
178
+
179
+ { reason: reason, category: category, quantity: val }
180
+ end
181
+
182
+ item_header = { type: 'client_report' }
183
+ item_payload = {
184
+ timestamp: Sentry.utc_now.iso8601,
185
+ discarded_events: discarded_events_hash
186
+ }
187
+
188
+ @discarded_events = Hash.new(0)
189
+ @last_client_report_sent = Time.now
190
+
191
+ [item_header, item_payload]
192
+ end
193
+
194
+ def reject_rate_limited_items(envelope)
195
+ envelope.items.reject! do |item|
196
+ if is_rate_limited?(item.type)
197
+ log_info("[Transport] Envelope item [#{item.type}] not sent: rate limiting")
198
+ record_lost_event(:ratelimit_backoff, item.type)
199
+
200
+ true
201
+ else
202
+ false
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ require "sentry/transport/dummy_transport"
210
+ require "sentry/transport/http_transport"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ module ArgumentCheckingHelper
5
+ private
6
+
7
+ def check_argument_type!(argument, expected_type)
8
+ unless argument.is_a?(expected_type)
9
+ raise ArgumentError, "expect the argument to be a #{expected_type}, got #{argument.class} (#{argument.inspect})"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ module CustomInspection
5
+ def inspect
6
+ attr_strings = (instance_variables - self.class::SKIP_INSPECTION_ATTRIBUTES).each_with_object([]) do |attr, result|
7
+ value = instance_variable_get(attr)
8
+ result << "#{attr}=#{value.inspect}" if value
9
+ end
10
+
11
+ "#<#{self.class.name} #{attr_strings.join(", ")}>"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ module Utils
5
+ module ExceptionCauseChain
6
+ def self.exception_to_array(exception)
7
+ exceptions = [exception]
8
+
9
+ while exception.cause
10
+ exception = exception.cause
11
+ break if exceptions.any? { |e| e.object_id == exception.object_id }
12
+
13
+ exceptions << exception
14
+ end
15
+
16
+ exceptions
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ module LoggingHelper
5
+ def log_error(message, exception, debug: false)
6
+ message = "#{message}: #{exception.message}"
7
+ message += "\n#{exception.backtrace.join("\n")}" if debug
8
+
9
+ @logger.error(LOGGER_PROGNAME) do
10
+ message
11
+ end
12
+ end
13
+
14
+ def log_info(message)
15
+ @logger.info(LOGGER_PROGNAME) { message }
16
+ end
17
+
18
+ def log_debug(message)
19
+ @logger.debug(LOGGER_PROGNAME) { message }
20
+ end
21
+
22
+ def log_warn(message)
23
+ @logger.warn(LOGGER_PROGNAME) { message }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ # Based on ActionDispatch::RemoteIp. All security-related precautions from that
6
+ # middleware have been removed, because the Event IP just needs to be accurate,
7
+ # and spoofing an IP here only makes data inaccurate, not insecure. Don't re-use
8
+ # this module if you have to *trust* the IP address.
9
+ module Sentry
10
+ module Utils
11
+ class RealIp
12
+ LOCAL_ADDRESSES = [
13
+ "127.0.0.1", # localhost IPv4
14
+ "::1", # localhost IPv6
15
+ "fc00::/7", # private IPv6 range fc00::/7
16
+ "10.0.0.0/8", # private IPv4 range 10.x.x.x
17
+ "172.16.0.0/12", # private IPv4 range 172.16.0.0 .. 172.31.255.255
18
+ "192.168.0.0/16", # private IPv4 range 192.168.x.x
19
+ ]
20
+
21
+ attr_reader :ip
22
+
23
+ def initialize(
24
+ remote_addr: nil,
25
+ client_ip: nil,
26
+ real_ip: nil,
27
+ forwarded_for: nil,
28
+ trusted_proxies: []
29
+ )
30
+ @remote_addr = remote_addr
31
+ @client_ip = client_ip
32
+ @real_ip = real_ip
33
+ @forwarded_for = forwarded_for
34
+ @trusted_proxies = (LOCAL_ADDRESSES + Array(trusted_proxies)).map do |proxy|
35
+ if proxy.is_a?(IPAddr)
36
+ proxy
37
+ else
38
+ IPAddr.new(proxy.to_s)
39
+ end
40
+ end.uniq
41
+ end
42
+
43
+ def calculate_ip
44
+ # CGI environment variable set by Rack
45
+ remote_addr = ips_from(@remote_addr).last
46
+
47
+ # Could be a CSV list and/or repeated headers that were concatenated.
48
+ client_ips = ips_from(@client_ip)
49
+ real_ips = ips_from(@real_ip)
50
+
51
+ # The first address in this list is the original client, followed by
52
+ # the IPs of successive proxies. We want to search starting from the end
53
+ # until we find the first proxy that we do not trust.
54
+ forwarded_ips = ips_from(@forwarded_for).reverse
55
+
56
+ ips = [client_ips, real_ips, forwarded_ips, remote_addr].flatten.compact
57
+
58
+ # If every single IP option is in the trusted list, just return REMOTE_ADDR
59
+ @ip = filter_trusted_proxy_addresses(ips).first || remote_addr
60
+ end
61
+
62
+ protected
63
+
64
+ def ips_from(header)
65
+ # Split the comma-separated list into an array of strings
66
+ ips = header ? header.strip.split(/[,\s]+/) : []
67
+ ips.select do |ip|
68
+ begin
69
+ # Only return IPs that are valid according to the IPAddr#new method
70
+ range = IPAddr.new(ip).to_range
71
+ # we want to make sure nobody is sneaking a netmask in
72
+ range.begin == range.end
73
+ rescue ArgumentError
74
+ nil
75
+ end
76
+ end
77
+ end
78
+
79
+ def filter_trusted_proxy_addresses(ips)
80
+ ips.reject { |ip| @trusted_proxies.any? { |proxy| proxy === ip } }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ module Utils
5
+ module RequestId
6
+ REQUEST_ID_HEADERS = %w(action_dispatch.request_id HTTP_X_REQUEST_ID).freeze
7
+
8
+ # Request ID based on ActionDispatch::RequestId
9
+ def self.read_from(env)
10
+ REQUEST_ID_HEADERS.each do |key|
11
+ request_id = env[key]
12
+ return request_id if request_id
13
+ end
14
+ nil
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ VERSION = "5.4.2"
5
+ end