sentry-ruby 5.3.1 → 5.16.1

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 (76) 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/Gemfile +26 -0
  7. data/Makefile +4 -0
  8. data/README.md +11 -8
  9. data/Rakefile +20 -0
  10. data/bin/console +18 -0
  11. data/bin/setup +8 -0
  12. data/lib/sentry/background_worker.rb +79 -0
  13. data/lib/sentry/backpressure_monitor.rb +75 -0
  14. data/lib/sentry/backtrace.rb +124 -0
  15. data/lib/sentry/baggage.rb +70 -0
  16. data/lib/sentry/breadcrumb/sentry_logger.rb +90 -0
  17. data/lib/sentry/breadcrumb.rb +76 -0
  18. data/lib/sentry/breadcrumb_buffer.rb +64 -0
  19. data/lib/sentry/check_in_event.rb +60 -0
  20. data/lib/sentry/client.rb +248 -0
  21. data/lib/sentry/configuration.rb +650 -0
  22. data/lib/sentry/core_ext/object/deep_dup.rb +61 -0
  23. data/lib/sentry/core_ext/object/duplicable.rb +155 -0
  24. data/lib/sentry/cron/configuration.rb +23 -0
  25. data/lib/sentry/cron/monitor_check_ins.rb +75 -0
  26. data/lib/sentry/cron/monitor_config.rb +53 -0
  27. data/lib/sentry/cron/monitor_schedule.rb +42 -0
  28. data/lib/sentry/dsn.rb +53 -0
  29. data/lib/sentry/envelope.rb +93 -0
  30. data/lib/sentry/error_event.rb +38 -0
  31. data/lib/sentry/event.rb +156 -0
  32. data/lib/sentry/exceptions.rb +9 -0
  33. data/lib/sentry/hub.rb +316 -0
  34. data/lib/sentry/integrable.rb +32 -0
  35. data/lib/sentry/interface.rb +16 -0
  36. data/lib/sentry/interfaces/exception.rb +43 -0
  37. data/lib/sentry/interfaces/request.rb +134 -0
  38. data/lib/sentry/interfaces/single_exception.rb +67 -0
  39. data/lib/sentry/interfaces/stacktrace.rb +87 -0
  40. data/lib/sentry/interfaces/stacktrace_builder.rb +79 -0
  41. data/lib/sentry/interfaces/threads.rb +42 -0
  42. data/lib/sentry/linecache.rb +47 -0
  43. data/lib/sentry/logger.rb +20 -0
  44. data/lib/sentry/net/http.rb +106 -0
  45. data/lib/sentry/profiler.rb +233 -0
  46. data/lib/sentry/propagation_context.rb +134 -0
  47. data/lib/sentry/puma.rb +32 -0
  48. data/lib/sentry/rack/capture_exceptions.rb +79 -0
  49. data/lib/sentry/rack.rb +5 -0
  50. data/lib/sentry/rake.rb +28 -0
  51. data/lib/sentry/redis.rb +108 -0
  52. data/lib/sentry/release_detector.rb +39 -0
  53. data/lib/sentry/scope.rb +360 -0
  54. data/lib/sentry/session.rb +33 -0
  55. data/lib/sentry/session_flusher.rb +90 -0
  56. data/lib/sentry/span.rb +273 -0
  57. data/lib/sentry/test_helper.rb +84 -0
  58. data/lib/sentry/transaction.rb +359 -0
  59. data/lib/sentry/transaction_event.rb +80 -0
  60. data/lib/sentry/transport/configuration.rb +98 -0
  61. data/lib/sentry/transport/dummy_transport.rb +21 -0
  62. data/lib/sentry/transport/http_transport.rb +206 -0
  63. data/lib/sentry/transport/spotlight_transport.rb +50 -0
  64. data/lib/sentry/transport.rb +225 -0
  65. data/lib/sentry/utils/argument_checking_helper.rb +19 -0
  66. data/lib/sentry/utils/custom_inspection.rb +14 -0
  67. data/lib/sentry/utils/encoding_helper.rb +22 -0
  68. data/lib/sentry/utils/exception_cause_chain.rb +20 -0
  69. data/lib/sentry/utils/logging_helper.rb +26 -0
  70. data/lib/sentry/utils/real_ip.rb +84 -0
  71. data/lib/sentry/utils/request_id.rb +18 -0
  72. data/lib/sentry/version.rb +5 -0
  73. data/lib/sentry-ruby.rb +580 -0
  74. data/sentry-ruby-core.gemspec +23 -0
  75. data/sentry-ruby.gemspec +24 -0
  76. metadata +75 -16
data/lib/sentry/hub.rb ADDED
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sentry/scope"
4
+ require "sentry/client"
5
+ require "sentry/session"
6
+
7
+ module Sentry
8
+ class Hub
9
+ include ArgumentCheckingHelper
10
+
11
+ attr_reader :last_event_id
12
+
13
+ def initialize(client, scope)
14
+ first_layer = Layer.new(client, scope)
15
+ @stack = [first_layer]
16
+ @last_event_id = nil
17
+ end
18
+
19
+ def new_from_top
20
+ Hub.new(current_client, current_scope)
21
+ end
22
+
23
+ def current_client
24
+ current_layer&.client
25
+ end
26
+
27
+ def configuration
28
+ current_client.configuration
29
+ end
30
+
31
+ def current_scope
32
+ current_layer&.scope
33
+ end
34
+
35
+ def clone
36
+ layer = current_layer
37
+
38
+ if layer
39
+ scope = layer.scope&.dup
40
+
41
+ Hub.new(layer.client, scope)
42
+ end
43
+ end
44
+
45
+ def bind_client(client)
46
+ layer = current_layer
47
+
48
+ if layer
49
+ layer.client = client
50
+ end
51
+ end
52
+
53
+ def configure_scope(&block)
54
+ block.call(current_scope)
55
+ end
56
+
57
+ def with_scope(&block)
58
+ push_scope
59
+ yield(current_scope)
60
+ ensure
61
+ pop_scope
62
+ end
63
+
64
+ def push_scope
65
+ new_scope =
66
+ if current_scope
67
+ current_scope.dup
68
+ else
69
+ Scope.new
70
+ end
71
+
72
+ @stack << Layer.new(current_client, new_scope)
73
+ end
74
+
75
+ def pop_scope
76
+ @stack.pop
77
+ end
78
+
79
+ def start_transaction(transaction: nil, custom_sampling_context: {}, instrumenter: :sentry, **options)
80
+ return unless configuration.tracing_enabled?
81
+ return unless instrumenter == configuration.instrumenter
82
+
83
+ transaction ||= Transaction.new(**options.merge(hub: self))
84
+
85
+ sampling_context = {
86
+ transaction_context: transaction.to_hash,
87
+ parent_sampled: transaction.parent_sampled
88
+ }
89
+
90
+ sampling_context.merge!(custom_sampling_context)
91
+ transaction.set_initial_sample_decision(sampling_context: sampling_context)
92
+
93
+ transaction.start_profiler!
94
+
95
+ transaction
96
+ end
97
+
98
+ def with_child_span(instrumenter: :sentry, **attributes, &block)
99
+ return yield(nil) unless instrumenter == configuration.instrumenter
100
+
101
+ current_span = current_scope.get_span
102
+ return yield(nil) unless current_span
103
+
104
+ result = nil
105
+
106
+ begin
107
+ current_span.with_child_span(**attributes) do |child_span|
108
+ current_scope.set_span(child_span)
109
+ result = yield(child_span)
110
+ end
111
+ ensure
112
+ current_scope.set_span(current_span)
113
+ end
114
+
115
+ result
116
+ end
117
+
118
+ def capture_exception(exception, **options, &block)
119
+ if RUBY_PLATFORM == "java"
120
+ check_argument_type!(exception, ::Exception, ::Java::JavaLang::Throwable)
121
+ else
122
+ check_argument_type!(exception, ::Exception)
123
+ end
124
+
125
+ return if Sentry.exception_captured?(exception)
126
+
127
+ return unless current_client
128
+
129
+ options[:hint] ||= {}
130
+ options[:hint][:exception] = exception
131
+
132
+ event = current_client.event_from_exception(exception, options[:hint])
133
+
134
+ return unless event
135
+
136
+ current_scope.session&.update_from_exception(event.exception)
137
+
138
+ capture_event(event, **options, &block).tap do
139
+ # mark the exception as captured so we can use this information to avoid duplicated capturing
140
+ exception.instance_variable_set(Sentry::CAPTURED_SIGNATURE, true)
141
+ end
142
+ end
143
+
144
+ def capture_message(message, **options, &block)
145
+ check_argument_type!(message, ::String)
146
+
147
+ return unless current_client
148
+
149
+ options[:hint] ||= {}
150
+ options[:hint][:message] = message
151
+ backtrace = options.delete(:backtrace)
152
+ event = current_client.event_from_message(message, options[:hint], backtrace: backtrace)
153
+
154
+ return unless event
155
+
156
+ capture_event(event, **options, &block)
157
+ end
158
+
159
+ def capture_check_in(slug, status, **options)
160
+ check_argument_type!(slug, ::String)
161
+ check_argument_includes!(status, Sentry::CheckInEvent::VALID_STATUSES)
162
+
163
+ return unless current_client
164
+
165
+ options[:hint] ||= {}
166
+ options[:hint][:slug] = slug
167
+
168
+ event = current_client.event_from_check_in(
169
+ slug,
170
+ status,
171
+ options[:hint],
172
+ duration: options.delete(:duration),
173
+ monitor_config: options.delete(:monitor_config),
174
+ check_in_id: options.delete(:check_in_id)
175
+ )
176
+
177
+ return unless event
178
+
179
+ capture_event(event, **options)
180
+ event.check_in_id
181
+ end
182
+
183
+ def capture_event(event, **options, &block)
184
+ check_argument_type!(event, Sentry::Event)
185
+
186
+ return unless current_client
187
+
188
+ hint = options.delete(:hint) || {}
189
+ scope = current_scope.dup
190
+
191
+ if block
192
+ block.call(scope)
193
+ elsif custom_scope = options[:scope]
194
+ scope.update_from_scope(custom_scope)
195
+ elsif !options.empty?
196
+ scope.update_from_options(**options)
197
+ end
198
+
199
+ event = current_client.capture_event(event, scope, hint)
200
+
201
+ if event && configuration.debug
202
+ configuration.log_debug(event.to_json_compatible)
203
+ end
204
+
205
+ @last_event_id = event&.event_id if event.is_a?(Sentry::ErrorEvent)
206
+ event
207
+ end
208
+
209
+ def add_breadcrumb(breadcrumb, hint: {})
210
+ return unless configuration.enabled_in_current_env?
211
+
212
+ if before_breadcrumb = current_client.configuration.before_breadcrumb
213
+ breadcrumb = before_breadcrumb.call(breadcrumb, hint)
214
+ end
215
+
216
+ return unless breadcrumb
217
+
218
+ current_scope.add_breadcrumb(breadcrumb)
219
+ end
220
+
221
+ # this doesn't do anything to the already initialized background worker
222
+ # but it temporarily disables dispatching events to it
223
+ def with_background_worker_disabled(&block)
224
+ original_background_worker_threads = configuration.background_worker_threads
225
+ configuration.background_worker_threads = 0
226
+
227
+ block.call
228
+ ensure
229
+ configuration.background_worker_threads = original_background_worker_threads
230
+ end
231
+
232
+ def start_session
233
+ return unless current_scope
234
+ current_scope.set_session(Session.new)
235
+ end
236
+
237
+ def end_session
238
+ return unless current_scope
239
+ session = current_scope.session
240
+ current_scope.set_session(nil)
241
+
242
+ return unless session
243
+ session.close
244
+ Sentry.session_flusher.add_session(session)
245
+ end
246
+
247
+ def with_session_tracking(&block)
248
+ return yield unless configuration.auto_session_tracking
249
+
250
+ start_session
251
+ yield
252
+ ensure
253
+ end_session
254
+ end
255
+
256
+ def get_traceparent
257
+ return nil unless current_scope
258
+
259
+ current_scope.get_span&.to_sentry_trace ||
260
+ current_scope.propagation_context.get_traceparent
261
+ end
262
+
263
+ def get_baggage
264
+ return nil unless current_scope
265
+
266
+ current_scope.get_span&.to_baggage ||
267
+ current_scope.propagation_context.get_baggage&.serialize
268
+ end
269
+
270
+ def get_trace_propagation_headers
271
+ headers = {}
272
+
273
+ traceparent = get_traceparent
274
+ headers[SENTRY_TRACE_HEADER_NAME] = traceparent if traceparent
275
+
276
+ baggage = get_baggage
277
+ headers[BAGGAGE_HEADER_NAME] = baggage if baggage && !baggage.empty?
278
+
279
+ headers
280
+ end
281
+
282
+ def continue_trace(env, **options)
283
+ configure_scope { |s| s.generate_propagation_context(env) }
284
+
285
+ return nil unless configuration.tracing_enabled?
286
+
287
+ propagation_context = current_scope.propagation_context
288
+ return nil unless propagation_context.incoming_trace
289
+
290
+ Transaction.new(
291
+ hub: self,
292
+ trace_id: propagation_context.trace_id,
293
+ parent_span_id: propagation_context.parent_span_id,
294
+ parent_sampled: propagation_context.parent_sampled,
295
+ baggage: propagation_context.baggage,
296
+ **options
297
+ )
298
+ end
299
+
300
+ private
301
+
302
+ def current_layer
303
+ @stack.last
304
+ end
305
+
306
+ class Layer
307
+ attr_accessor :client
308
+ attr_reader :scope
309
+
310
+ def initialize(client, scope)
311
+ @client = client
312
+ @scope = scope
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ module Integrable
5
+ def register_integration(name:, version:)
6
+ Sentry.register_integration(name, version)
7
+ @integration_name = name
8
+ end
9
+
10
+ def integration_name
11
+ @integration_name
12
+ end
13
+
14
+ def capture_exception(exception, **options, &block)
15
+ options[:hint] ||= {}
16
+ options[:hint][:integration] = integration_name
17
+ Sentry.capture_exception(exception, **options, &block)
18
+ end
19
+
20
+ def capture_message(message, **options, &block)
21
+ options[:hint] ||= {}
22
+ options[:hint][:integration] = integration_name
23
+ Sentry.capture_message(message, **options, &block)
24
+ end
25
+
26
+ def capture_check_in(slug, status, **options, &block)
27
+ options[:hint] ||= {}
28
+ options[:hint][:integration] = integration_name
29
+ Sentry.capture_check_in(slug, status, **options, &block)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ class Interface
5
+ # @return [Hash]
6
+ def to_hash
7
+ Hash[instance_variables.map { |name| [name[1..-1].to_sym, instance_variable_get(name)] }]
8
+ end
9
+ end
10
+ end
11
+
12
+ require "sentry/interfaces/exception"
13
+ require "sentry/interfaces/request"
14
+ require "sentry/interfaces/single_exception"
15
+ require "sentry/interfaces/stacktrace"
16
+ require "sentry/interfaces/threads"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ require "set"
3
+
4
+ module Sentry
5
+ class ExceptionInterface < Interface
6
+ # @return [<Array[SingleExceptionInterface]>]
7
+ attr_reader :values
8
+
9
+ # @param exceptions [Array<SingleExceptionInterface>]
10
+ def initialize(exceptions:)
11
+ @values = exceptions
12
+ end
13
+
14
+ # @return [Hash]
15
+ def to_hash
16
+ data = super
17
+ data[:values] = data[:values].map(&:to_hash) if data[:values]
18
+ data
19
+ end
20
+
21
+ # Builds ExceptionInterface with given exception and stacktrace_builder.
22
+ # @param exception [Exception]
23
+ # @param stacktrace_builder [StacktraceBuilder]
24
+ # @see SingleExceptionInterface#build_with_stacktrace
25
+ # @see SingleExceptionInterface#initialize
26
+ # @return [ExceptionInterface]
27
+ def self.build(exception:, stacktrace_builder:)
28
+ exceptions = Sentry::Utils::ExceptionCauseChain.exception_to_array(exception).reverse
29
+ processed_backtrace_ids = Set.new
30
+
31
+ exceptions = exceptions.map do |e|
32
+ if e.backtrace && !processed_backtrace_ids.include?(e.backtrace.object_id)
33
+ processed_backtrace_ids << e.backtrace.object_id
34
+ SingleExceptionInterface.build_with_stacktrace(exception: e, stacktrace_builder: stacktrace_builder)
35
+ else
36
+ SingleExceptionInterface.new(exception: exception)
37
+ end
38
+ end
39
+
40
+ new(exceptions: exceptions)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ class RequestInterface < Interface
5
+ REQUEST_ID_HEADERS = %w(action_dispatch.request_id HTTP_X_REQUEST_ID).freeze
6
+ CONTENT_HEADERS = %w(CONTENT_TYPE CONTENT_LENGTH).freeze
7
+ IP_HEADERS = [
8
+ "REMOTE_ADDR",
9
+ "HTTP_CLIENT_IP",
10
+ "HTTP_X_REAL_IP",
11
+ "HTTP_X_FORWARDED_FOR"
12
+ ].freeze
13
+
14
+ # See Sentry server default limits at
15
+ # https://github.com/getsentry/sentry/blob/master/src/sentry/conf/server.py
16
+ MAX_BODY_LIMIT = 4096 * 4
17
+
18
+ # @return [String]
19
+ attr_accessor :url
20
+
21
+ # @return [String]
22
+ attr_accessor :method
23
+
24
+ # @return [Hash]
25
+ attr_accessor :data
26
+
27
+ # @return [String]
28
+ attr_accessor :query_string
29
+
30
+ # @return [String]
31
+ attr_accessor :cookies
32
+
33
+ # @return [Hash]
34
+ attr_accessor :headers
35
+
36
+ # @return [Hash]
37
+ attr_accessor :env
38
+
39
+ # @param env [Hash]
40
+ # @param send_default_pii [Boolean]
41
+ # @param rack_env_whitelist [Array]
42
+ # @see Configuration#send_default_pii
43
+ # @see Configuration#rack_env_whitelist
44
+ def initialize(env:, send_default_pii:, rack_env_whitelist:)
45
+ env = env.dup
46
+
47
+ unless send_default_pii
48
+ # need to completely wipe out ip addresses
49
+ RequestInterface::IP_HEADERS.each do |header|
50
+ env.delete(header)
51
+ end
52
+ end
53
+
54
+ request = ::Rack::Request.new(env)
55
+
56
+ if send_default_pii
57
+ self.data = read_data_from(request)
58
+ self.cookies = request.cookies
59
+ self.query_string = request.query_string
60
+ end
61
+
62
+ self.url = request.scheme && request.url.split('?').first
63
+ self.method = request.request_method
64
+
65
+ self.headers = filter_and_format_headers(env, send_default_pii)
66
+ self.env = filter_and_format_env(env, rack_env_whitelist)
67
+ end
68
+
69
+ private
70
+
71
+ def read_data_from(request)
72
+ if request.form_data?
73
+ request.POST
74
+ elsif request.body # JSON requests, etc
75
+ data = request.body.read(MAX_BODY_LIMIT)
76
+ data = Utils::EncodingHelper.encode_to_utf_8(data.to_s)
77
+ request.body.rewind
78
+ data
79
+ end
80
+ rescue IOError => e
81
+ e.message
82
+ end
83
+
84
+ def filter_and_format_headers(env, send_default_pii)
85
+ env.each_with_object({}) do |(key, value), memo|
86
+ begin
87
+ key = key.to_s # rack env can contain symbols
88
+ next memo['X-Request-Id'] ||= Utils::RequestId.read_from(env) if Utils::RequestId::REQUEST_ID_HEADERS.include?(key)
89
+ next if is_server_protocol?(key, value, env["SERVER_PROTOCOL"])
90
+ next if is_skippable_header?(key)
91
+ next if key == "HTTP_AUTHORIZATION" && !send_default_pii
92
+
93
+ # Rack stores headers as HTTP_WHAT_EVER, we need What-Ever
94
+ key = key.sub(/^HTTP_/, "")
95
+ key = key.split('_').map(&:capitalize).join('-')
96
+
97
+ memo[key] = Utils::EncodingHelper.encode_to_utf_8(value.to_s)
98
+ rescue StandardError => e
99
+ # Rails adds objects to the Rack env that can sometimes raise exceptions
100
+ # when `to_s` is called.
101
+ # See: https://github.com/rails/rails/blob/master/actionpack/lib/action_dispatch/middleware/remote_ip.rb#L134
102
+ Sentry.logger.warn(LOGGER_PROGNAME) { "Error raised while formatting headers: #{e.message}" }
103
+ next
104
+ end
105
+ end
106
+ end
107
+
108
+ def is_skippable_header?(key)
109
+ key.upcase != key || # lower-case envs aren't real http headers
110
+ key == "HTTP_COOKIE" || # Cookies don't go here, they go somewhere else
111
+ !(key.start_with?('HTTP_') || CONTENT_HEADERS.include?(key))
112
+ end
113
+
114
+ # In versions < 3, Rack adds in an incorrect HTTP_VERSION key, which causes downstream
115
+ # to think this is a Version header. Instead, this is mapped to
116
+ # env['SERVER_PROTOCOL']. But we don't want to ignore a valid header
117
+ # if the request has legitimately sent a Version header themselves.
118
+ # See: https://github.com/rack/rack/blob/028438f/lib/rack/handler/cgi.rb#L29
119
+ def is_server_protocol?(key, value, protocol_version)
120
+ rack_version = Gem::Version.new(::Rack.release)
121
+ return false if rack_version >= Gem::Version.new("3.0")
122
+
123
+ key == 'HTTP_VERSION' && value == protocol_version
124
+ end
125
+
126
+ def filter_and_format_env(env, rack_env_whitelist)
127
+ return env if rack_env_whitelist.empty?
128
+
129
+ env.select do |k, _v|
130
+ rack_env_whitelist.include? k.to_s
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sentry/utils/exception_cause_chain"
4
+
5
+ module Sentry
6
+ class SingleExceptionInterface < Interface
7
+ include CustomInspection
8
+
9
+ SKIP_INSPECTION_ATTRIBUTES = [:@stacktrace]
10
+ PROBLEMATIC_LOCAL_VALUE_REPLACEMENT = "[ignored due to error]".freeze
11
+ OMISSION_MARK = "...".freeze
12
+ MAX_LOCAL_BYTES = 1024
13
+
14
+ attr_reader :type, :module, :thread_id, :stacktrace
15
+ attr_accessor :value
16
+
17
+ def initialize(exception:, stacktrace: nil)
18
+ @type = exception.class.to_s
19
+ exception_message =
20
+ if exception.respond_to?(:detailed_message)
21
+ exception.detailed_message(highlight: false)
22
+ else
23
+ exception.message || ""
24
+ end
25
+ exception_message = exception_message.inspect unless exception_message.is_a?(String)
26
+
27
+ @value = Utils::EncodingHelper.encode_to_utf_8(exception_message.byteslice(0..Event::MAX_MESSAGE_SIZE_IN_BYTES))
28
+
29
+ @module = exception.class.to_s.split('::')[0...-1].join('::')
30
+ @thread_id = Thread.current.object_id
31
+ @stacktrace = stacktrace
32
+ end
33
+
34
+ def to_hash
35
+ data = super
36
+ data[:stacktrace] = data[:stacktrace].to_hash if data[:stacktrace]
37
+ data
38
+ end
39
+
40
+ # patch this method if you want to change an exception's stacktrace frames
41
+ # also see `StacktraceBuilder.build`.
42
+ def self.build_with_stacktrace(exception:, stacktrace_builder:)
43
+ stacktrace = stacktrace_builder.build(backtrace: exception.backtrace)
44
+
45
+ if locals = exception.instance_variable_get(:@sentry_locals)
46
+ locals.each do |k, v|
47
+ locals[k] =
48
+ begin
49
+ v = v.inspect unless v.is_a?(String)
50
+
51
+ if v.length >= MAX_LOCAL_BYTES
52
+ v = v.byteslice(0..MAX_LOCAL_BYTES - 1) + OMISSION_MARK
53
+ end
54
+
55
+ Utils::EncodingHelper.encode_to_utf_8(v)
56
+ rescue StandardError
57
+ PROBLEMATIC_LOCAL_VALUE_REPLACEMENT
58
+ end
59
+ end
60
+
61
+ stacktrace.frames.last.vars = locals
62
+ end
63
+
64
+ new(exception: exception, stacktrace: stacktrace)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ class StacktraceInterface
5
+ # @return [<Array[Frame]>]
6
+ attr_reader :frames
7
+
8
+ # @param frames [<Array[Frame]>]
9
+ def initialize(frames:)
10
+ @frames = frames
11
+ end
12
+
13
+ # @return [Hash]
14
+ def to_hash
15
+ { frames: @frames.map(&:to_hash) }
16
+ end
17
+
18
+ # @return [String]
19
+ def inspect
20
+ @frames.map(&:to_s)
21
+ end
22
+
23
+ private
24
+
25
+ # Not actually an interface, but I want to use the same style
26
+ class Frame < Interface
27
+ attr_accessor :abs_path, :context_line, :function, :in_app, :filename,
28
+ :lineno, :module, :pre_context, :post_context, :vars
29
+
30
+ def initialize(project_root, line)
31
+ @project_root = project_root
32
+
33
+ @abs_path = line.file
34
+ @function = line.method if line.method
35
+ @lineno = line.number
36
+ @in_app = line.in_app
37
+ @module = line.module_name if line.module_name
38
+ @filename = compute_filename
39
+ end
40
+
41
+ def to_s
42
+ "#{@filename}:#{@lineno}"
43
+ end
44
+
45
+ def compute_filename
46
+ return if abs_path.nil?
47
+
48
+ prefix =
49
+ if under_project_root? && in_app
50
+ @project_root
51
+ elsif under_project_root?
52
+ longest_load_path || @project_root
53
+ else
54
+ longest_load_path
55
+ end
56
+
57
+ prefix ? abs_path[prefix.to_s.chomp(File::SEPARATOR).length + 1..-1] : abs_path
58
+ end
59
+
60
+ def set_context(linecache, context_lines)
61
+ return unless abs_path
62
+
63
+ @pre_context, @context_line, @post_context = \
64
+ linecache.get_file_context(abs_path, lineno, context_lines)
65
+ end
66
+
67
+ def to_hash(*args)
68
+ data = super(*args)
69
+ data.delete(:vars) unless vars && !vars.empty?
70
+ data.delete(:pre_context) unless pre_context && !pre_context.empty?
71
+ data.delete(:post_context) unless post_context && !post_context.empty?
72
+ data.delete(:context_line) unless context_line && !context_line.empty?
73
+ data
74
+ end
75
+
76
+ private
77
+
78
+ def under_project_root?
79
+ @project_root && abs_path.start_with?(@project_root)
80
+ end
81
+
82
+ def longest_load_path
83
+ $LOAD_PATH.select { |path| abs_path.start_with?(path.to_s) }.max_by(&:size)
84
+ end
85
+ end
86
+ end
87
+ end