sentry-ruby 5.3.0 → 5.8.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +11 -0
  3. data/.rspec +2 -0
  4. data/.yardopts +2 -0
  5. data/CHANGELOG.md +313 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +31 -0
  8. data/Makefile +4 -0
  9. data/README.md +10 -6
  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/baggage.rb +81 -0
  16. data/lib/sentry/breadcrumb/sentry_logger.rb +90 -0
  17. data/lib/sentry/breadcrumb.rb +70 -0
  18. data/lib/sentry/breadcrumb_buffer.rb +64 -0
  19. data/lib/sentry/client.rb +207 -0
  20. data/lib/sentry/configuration.rb +543 -0
  21. data/lib/sentry/core_ext/object/deep_dup.rb +61 -0
  22. data/lib/sentry/core_ext/object/duplicable.rb +155 -0
  23. data/lib/sentry/dsn.rb +53 -0
  24. data/lib/sentry/envelope.rb +96 -0
  25. data/lib/sentry/error_event.rb +38 -0
  26. data/lib/sentry/event.rb +178 -0
  27. data/lib/sentry/exceptions.rb +9 -0
  28. data/lib/sentry/hub.rb +241 -0
  29. data/lib/sentry/integrable.rb +26 -0
  30. data/lib/sentry/interface.rb +16 -0
  31. data/lib/sentry/interfaces/exception.rb +43 -0
  32. data/lib/sentry/interfaces/request.rb +134 -0
  33. data/lib/sentry/interfaces/single_exception.rb +65 -0
  34. data/lib/sentry/interfaces/stacktrace.rb +87 -0
  35. data/lib/sentry/interfaces/stacktrace_builder.rb +79 -0
  36. data/lib/sentry/interfaces/threads.rb +42 -0
  37. data/lib/sentry/linecache.rb +47 -0
  38. data/lib/sentry/logger.rb +20 -0
  39. data/lib/sentry/net/http.rb +103 -0
  40. data/lib/sentry/rack/capture_exceptions.rb +82 -0
  41. data/lib/sentry/rack.rb +5 -0
  42. data/lib/sentry/rake.rb +41 -0
  43. data/lib/sentry/redis.rb +107 -0
  44. data/lib/sentry/release_detector.rb +39 -0
  45. data/lib/sentry/scope.rb +339 -0
  46. data/lib/sentry/session.rb +33 -0
  47. data/lib/sentry/session_flusher.rb +90 -0
  48. data/lib/sentry/span.rb +236 -0
  49. data/lib/sentry/test_helper.rb +78 -0
  50. data/lib/sentry/transaction.rb +345 -0
  51. data/lib/sentry/transaction_event.rb +53 -0
  52. data/lib/sentry/transport/configuration.rb +25 -0
  53. data/lib/sentry/transport/dummy_transport.rb +21 -0
  54. data/lib/sentry/transport/http_transport.rb +175 -0
  55. data/lib/sentry/transport.rb +214 -0
  56. data/lib/sentry/utils/argument_checking_helper.rb +13 -0
  57. data/lib/sentry/utils/custom_inspection.rb +14 -0
  58. data/lib/sentry/utils/encoding_helper.rb +22 -0
  59. data/lib/sentry/utils/exception_cause_chain.rb +20 -0
  60. data/lib/sentry/utils/logging_helper.rb +26 -0
  61. data/lib/sentry/utils/real_ip.rb +84 -0
  62. data/lib/sentry/utils/request_id.rb +18 -0
  63. data/lib/sentry/version.rb +5 -0
  64. data/lib/sentry-ruby.rb +511 -0
  65. data/sentry-ruby-core.gemspec +23 -0
  66. data/sentry-ruby.gemspec +24 -0
  67. metadata +66 -16
@@ -0,0 +1,214 @@
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_headers = {
140
+ event_id: event_id,
141
+ dsn: @dsn.to_s,
142
+ sdk: Sentry.sdk_meta,
143
+ sent_at: Sentry.utc_now.iso8601
144
+ }
145
+
146
+ if event.is_a?(TransactionEvent) && event.dynamic_sampling_context
147
+ envelope_headers[:trace] = event.dynamic_sampling_context
148
+ end
149
+
150
+ envelope = Envelope.new(envelope_headers)
151
+
152
+ envelope.add_item(
153
+ { type: item_type, content_type: 'application/json' },
154
+ event_payload
155
+ )
156
+
157
+ client_report_headers, client_report_payload = fetch_pending_client_report
158
+ envelope.add_item(client_report_headers, client_report_payload) if client_report_headers
159
+
160
+ envelope
161
+ end
162
+
163
+ def record_lost_event(reason, item_type)
164
+ return unless @send_client_reports
165
+ return unless CLIENT_REPORT_REASONS.include?(reason)
166
+
167
+ @discarded_events[[reason, item_type]] += 1
168
+ end
169
+
170
+ private
171
+
172
+ def fetch_pending_client_report
173
+ return nil unless @send_client_reports
174
+ return nil if @last_client_report_sent > Time.now - CLIENT_REPORT_INTERVAL
175
+ return nil if @discarded_events.empty?
176
+
177
+ discarded_events_hash = @discarded_events.map do |key, val|
178
+ reason, type = key
179
+
180
+ # 'event' has to be mapped to 'error'
181
+ category = type == 'transaction' ? 'transaction' : 'error'
182
+
183
+ { reason: reason, category: category, quantity: val }
184
+ end
185
+
186
+ item_header = { type: 'client_report' }
187
+ item_payload = {
188
+ timestamp: Sentry.utc_now.iso8601,
189
+ discarded_events: discarded_events_hash
190
+ }
191
+
192
+ @discarded_events = Hash.new(0)
193
+ @last_client_report_sent = Time.now
194
+
195
+ [item_header, item_payload]
196
+ end
197
+
198
+ def reject_rate_limited_items(envelope)
199
+ envelope.items.reject! do |item|
200
+ if is_rate_limited?(item.type)
201
+ log_info("[Transport] Envelope item [#{item.type}] not sent: rate limiting")
202
+ record_lost_event(:ratelimit_backoff, item.type)
203
+
204
+ true
205
+ else
206
+ false
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ require "sentry/transport/dummy_transport"
214
+ 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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ module Utils
5
+ module EncodingHelper
6
+ def self.encode_to_utf_8(value)
7
+ if value.encoding != Encoding::UTF_8 && value.respond_to?(:force_encoding)
8
+ value = value.dup.force_encoding(Encoding::UTF_8)
9
+ end
10
+
11
+ value = value.scrub unless value.valid_encoding?
12
+ value
13
+ end
14
+
15
+ def self.valid_utf_8?(value)
16
+ return true unless value.respond_to?(:force_encoding)
17
+
18
+ value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
19
+ end
20
+ end
21
+ end
22
+ 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.8.0"
5
+ end