sentry-ruby 5.5.0 → 5.10.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.
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Sentry
6
+ class Profiler
7
+ VERSION = '1'
8
+ PLATFORM = 'ruby'
9
+ # 101 Hz in microseconds
10
+ DEFAULT_INTERVAL = 1e6 / 101
11
+ MICRO_TO_NANO_SECONDS = 1e3
12
+
13
+ attr_reader :sampled, :started, :event_id
14
+
15
+ def initialize(configuration)
16
+ @event_id = SecureRandom.uuid.delete('-')
17
+ @started = false
18
+ @sampled = nil
19
+
20
+ @profiling_enabled = defined?(StackProf) && configuration.profiling_enabled?
21
+ @profiles_sample_rate = configuration.profiles_sample_rate
22
+ @project_root = configuration.project_root
23
+ @app_dirs_pattern = configuration.app_dirs_pattern || Backtrace::APP_DIRS_PATTERN
24
+ @in_app_pattern = Regexp.new("^(#{@project_root}/)?#{@app_dirs_pattern}")
25
+ end
26
+
27
+ def start
28
+ return unless @sampled
29
+
30
+ @started = StackProf.start(interval: DEFAULT_INTERVAL,
31
+ mode: :wall,
32
+ raw: true,
33
+ aggregate: false)
34
+
35
+ @started ? log('Started') : log('Not started since running elsewhere')
36
+ end
37
+
38
+ def stop
39
+ return unless @sampled
40
+ return unless @started
41
+
42
+ StackProf.stop
43
+ log('Stopped')
44
+ end
45
+
46
+ # Sets initial sampling decision of the profile.
47
+ # @return [void]
48
+ def set_initial_sample_decision(transaction_sampled)
49
+ unless @profiling_enabled
50
+ @sampled = false
51
+ return
52
+ end
53
+
54
+ unless transaction_sampled
55
+ @sampled = false
56
+ log('Discarding profile because transaction not sampled')
57
+ return
58
+ end
59
+
60
+ case @profiles_sample_rate
61
+ when 0.0
62
+ @sampled = false
63
+ log('Discarding profile because sample_rate is 0')
64
+ return
65
+ when 1.0
66
+ @sampled = true
67
+ return
68
+ else
69
+ @sampled = Random.rand < @profiles_sample_rate
70
+ end
71
+
72
+ log('Discarding profile due to sampling decision') unless @sampled
73
+ end
74
+
75
+ def to_hash
76
+ return {} unless @sampled
77
+ return {} unless @started
78
+
79
+ results = StackProf.results
80
+ return {} unless results
81
+ return {} if results.empty?
82
+ return {} if results[:samples] == 0
83
+ return {} unless results[:raw]
84
+
85
+ frame_map = {}
86
+
87
+ frames = results[:frames].to_enum.with_index.map do |frame, idx|
88
+ frame_id, frame_data = frame
89
+
90
+ # need to map over stackprof frame ids to ours
91
+ frame_map[frame_id] = idx
92
+
93
+ file_path = frame_data[:file]
94
+ in_app = in_app?(file_path)
95
+ filename = compute_filename(file_path, in_app)
96
+ function, mod = split_module(frame_data[:name])
97
+
98
+ frame_hash = {
99
+ abs_path: file_path,
100
+ function: function,
101
+ filename: filename,
102
+ in_app: in_app
103
+ }
104
+
105
+ frame_hash[:module] = mod if mod
106
+ frame_hash[:lineno] = frame_data[:line] if frame_data[:line]
107
+
108
+ frame_hash
109
+ end
110
+
111
+ idx = 0
112
+ stacks = []
113
+ num_seen = []
114
+
115
+ # extract stacks from raw
116
+ # raw is a single array of [.., len_stack, *stack_frames(len_stack), num_stack_seen , ..]
117
+ while (len = results[:raw][idx])
118
+ idx += 1
119
+
120
+ # our call graph is reversed
121
+ stack = results[:raw].slice(idx, len).map { |id| frame_map[id] }.compact.reverse
122
+ stacks << stack
123
+
124
+ num_seen << results[:raw][idx + len]
125
+ idx += len + 1
126
+
127
+ log('Unknown frame in stack') if stack.size != len
128
+ end
129
+
130
+ idx = 0
131
+ elapsed_since_start_ns = 0
132
+ samples = []
133
+
134
+ num_seen.each_with_index do |n, i|
135
+ n.times do
136
+ # stackprof deltas are in microseconds
137
+ delta = results[:raw_timestamp_deltas][idx]
138
+ elapsed_since_start_ns += (delta * MICRO_TO_NANO_SECONDS).to_i
139
+ idx += 1
140
+
141
+ # Not sure why but some deltas are very small like 0/1 values,
142
+ # they pollute our flamegraph so just ignore them for now.
143
+ # Open issue at https://github.com/tmm1/stackprof/issues/201
144
+ next if delta < 10
145
+
146
+ samples << {
147
+ stack_id: i,
148
+ # TODO-neel-profiler we need to patch rb_profile_frames and write our own C extension to enable threading info.
149
+ # Till then, on multi-threaded servers like puma, we will get frames from other active threads when the one
150
+ # we're profiling is idle/sleeping/waiting for IO etc.
151
+ # https://bugs.ruby-lang.org/issues/10602
152
+ thread_id: '0',
153
+ elapsed_since_start_ns: elapsed_since_start_ns.to_s
154
+ }
155
+ end
156
+ end
157
+
158
+ log('Some samples thrown away') if samples.size != results[:samples]
159
+
160
+ if samples.size <= 2
161
+ log('Not enough samples, discarding profiler')
162
+ return {}
163
+ end
164
+
165
+ profile = {
166
+ frames: frames,
167
+ stacks: stacks,
168
+ samples: samples
169
+ }
170
+
171
+ {
172
+ event_id: @event_id,
173
+ platform: PLATFORM,
174
+ version: VERSION,
175
+ profile: profile
176
+ }
177
+ end
178
+
179
+ private
180
+
181
+ def log(message)
182
+ Sentry.logger.debug(LOGGER_PROGNAME) { "[Profiler] #{message}" }
183
+ end
184
+
185
+ def in_app?(abs_path)
186
+ abs_path.match?(@in_app_pattern)
187
+ end
188
+
189
+ # copied from stacktrace.rb since I don't want to touch existing code
190
+ # TODO-neel-profiler try to fetch this from stackprof once we patch
191
+ # the native extension
192
+ def compute_filename(abs_path, in_app)
193
+ return nil if abs_path.nil?
194
+
195
+ under_project_root = @project_root && abs_path.start_with?(@project_root)
196
+
197
+ prefix =
198
+ if under_project_root && in_app
199
+ @project_root
200
+ else
201
+ longest_load_path = $LOAD_PATH.select { |path| abs_path.start_with?(path.to_s) }.max_by(&:size)
202
+
203
+ if under_project_root
204
+ longest_load_path || @project_root
205
+ else
206
+ longest_load_path
207
+ end
208
+ end
209
+
210
+ prefix ? abs_path[prefix.to_s.chomp(File::SEPARATOR).length + 1..-1] : abs_path
211
+ end
212
+
213
+ def split_module(name)
214
+ # last module plus class/instance method
215
+ i = name.rindex('::')
216
+ function = i ? name[(i + 2)..-1] : name
217
+ mod = i ? name[0...i] : nil
218
+
219
+ [function, mod]
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentry
4
+ module Puma
5
+ module Server
6
+ def lowlevel_error(e, env, status=500)
7
+ result = super
8
+
9
+ begin
10
+ Sentry.capture_exception(e) do |scope|
11
+ scope.set_rack_env(env)
12
+ end
13
+ rescue
14
+ # if anything happens, we don't want to break the app
15
+ end
16
+
17
+ result
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ if defined?(Puma::Server)
24
+ Sentry.register_patch(Sentry::Puma::Server, Puma::Server)
25
+ end
@@ -52,7 +52,7 @@ module Sentry
52
52
  end
53
53
 
54
54
  def transaction_op
55
- "rack.request".freeze
55
+ "http.server".freeze
56
56
  end
57
57
 
58
58
  def capture_exception(exception, env)
data/lib/sentry/redis.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Sentry
4
4
  # @api private
5
5
  class Redis
6
- OP_NAME = "db.redis.command"
6
+ OP_NAME = "db.redis"
7
7
  LOGGER_NAME = :redis_logger
8
8
 
9
9
  def initialize(commands, host, port, db)
@@ -13,9 +13,14 @@ module Sentry
13
13
  def instrument
14
14
  return yield unless Sentry.initialized?
15
15
 
16
- record_span do
16
+ Sentry.with_child_span(op: OP_NAME, start_timestamp: Sentry.utc_now.to_f) do |span|
17
17
  yield.tap do
18
18
  record_breadcrumb
19
+
20
+ if span
21
+ span.set_description(commands_description)
22
+ span.set_data(:server, server_description)
23
+ end
19
24
  end
20
25
  end
21
26
  end
@@ -24,19 +29,8 @@ module Sentry
24
29
 
25
30
  attr_reader :commands, :host, :port, :db
26
31
 
27
- def record_span
28
- return yield unless (transaction = Sentry.get_current_scope.get_transaction) && transaction.sampled
29
-
30
- sentry_span = transaction.start_child(op: OP_NAME, start_timestamp: Sentry.utc_now.to_f)
31
-
32
- yield.tap do
33
- sentry_span.set_description(commands_description)
34
- sentry_span.set_data(:server, server_description)
35
- sentry_span.set_timestamp(Sentry.utc_now.to_f)
36
- end
37
- end
38
-
39
32
  def record_breadcrumb
33
+ return unless Sentry.initialized?
40
34
  return unless Sentry.configuration.breadcrumbs_logger.include?(LOGGER_NAME)
41
35
 
42
36
  Sentry.add_breadcrumb(
@@ -61,10 +55,16 @@ module Sentry
61
55
  def parsed_commands
62
56
  commands.map do |statement|
63
57
  command, key, *arguments = statement
58
+ command_set = { command: command.to_s.upcase }
59
+ command_set[:key] = key if Utils::EncodingHelper.valid_utf_8?(key)
64
60
 
65
- { command: command.to_s.upcase, key: key }.tap do |command_set|
66
- command_set[:arguments] = arguments.join(" ") if Sentry.configuration.send_default_pii
61
+ if Sentry.configuration.send_default_pii
62
+ command_set[:arguments] = arguments
63
+ .select { |a| Utils::EncodingHelper.valid_utf_8?(a) }
64
+ .join(" ")
67
65
  end
66
+
67
+ command_set
68
68
  end
69
69
  end
70
70
 
@@ -72,19 +72,32 @@ module Sentry
72
72
  "#{host}:#{port}/#{db}"
73
73
  end
74
74
 
75
- module Client
75
+ module OldClientPatch
76
76
  def logging(commands, &block)
77
- Sentry::Redis.new(commands, host, port, db).instrument do
78
- super
79
- end
77
+ Sentry::Redis.new(commands, host, port, db).instrument { super }
78
+ end
79
+ end
80
+
81
+ module GlobalRedisInstrumentation
82
+ def call(command, redis_config)
83
+ Sentry::Redis
84
+ .new([command], redis_config.host, redis_config.port, redis_config.db)
85
+ .instrument { super }
86
+ end
87
+
88
+ def call_pipelined(commands, redis_config)
89
+ Sentry::Redis
90
+ .new(commands, redis_config.host, redis_config.port, redis_config.db)
91
+ .instrument { super }
80
92
  end
81
93
  end
82
94
  end
83
95
  end
84
96
 
85
97
  if defined?(::Redis::Client)
86
- Sentry.register_patch do
87
- patch = Sentry::Redis::Client
88
- Redis::Client.prepend(patch) unless Redis::Client.ancestors.include?(patch)
98
+ if Gem::Version.new(::Redis::VERSION) < Gem::Version.new("5.0")
99
+ Sentry.register_patch(Sentry::Redis::OldClientPatch, ::Redis::Client)
100
+ elsif defined?(RedisClient)
101
+ RedisClient.register(Sentry::Redis::GlobalRedisInstrumentation)
89
102
  end
90
103
  end
data/lib/sentry/scope.rb CHANGED
@@ -58,8 +58,10 @@ module Sentry
58
58
  event.breadcrumbs = breadcrumbs
59
59
  event.rack_env = rack_env if rack_env
60
60
 
61
- unless @event_processors.empty?
62
- @event_processors.each do |processor_block|
61
+ all_event_processors = self.class.global_event_processors + @event_processors
62
+
63
+ unless all_event_processors.empty?
64
+ all_event_processors.each do |processor_block|
63
65
  event = processor_block.call(event, hint)
64
66
  end
65
67
  end
@@ -190,6 +192,10 @@ module Sentry
190
192
  # @return [Hash]
191
193
  def set_contexts(contexts_hash)
192
194
  check_argument_type!(contexts_hash, Hash)
195
+ contexts_hash.values.each do |val|
196
+ check_argument_type!(val, Hash)
197
+ end
198
+
193
199
  @contexts.merge!(contexts_hash) do |key, old, new|
194
200
  old.merge(new)
195
201
  end
@@ -303,7 +309,8 @@ module Sentry
303
309
  name: uname[:sysname] || RbConfig::CONFIG["host_os"],
304
310
  version: uname[:version],
305
311
  build: uname[:release],
306
- kernel_version: uname[:version]
312
+ kernel_version: uname[:version],
313
+ machine: uname[:machine]
307
314
  }
308
315
  end
309
316
  end
@@ -315,6 +322,22 @@ module Sentry
315
322
  version: RUBY_DESCRIPTION || Sentry.sys_command("ruby -v")
316
323
  }
317
324
  end
325
+
326
+ # Returns the global event processors array.
327
+ # @return [Array<Proc>]
328
+ def global_event_processors
329
+ @global_event_processors ||= []
330
+ end
331
+
332
+ # Adds a new global event processor [Proc].
333
+ # Sometimes we need a global event processor without needing to configure scope.
334
+ # These run before scope event processors.
335
+ #
336
+ # @param block [Proc]
337
+ # @return [void]
338
+ def add_global_event_processor(&block)
339
+ global_event_processors << block
340
+ end
318
341
  end
319
342
 
320
343
  end
data/lib/sentry/span.rb CHANGED
@@ -60,25 +60,28 @@ module Sentry
60
60
  # The Transaction object the Span belongs to.
61
61
  # Every span needs to be attached to a Transaction and their child spans will also inherit the same transaction.
62
62
  # @return [Transaction]
63
- attr_accessor :transaction
63
+ attr_reader :transaction
64
64
 
65
65
  def initialize(
66
+ transaction:,
66
67
  description: nil,
67
68
  op: nil,
68
69
  status: nil,
69
70
  trace_id: nil,
71
+ span_id: nil,
70
72
  parent_span_id: nil,
71
73
  sampled: nil,
72
74
  start_timestamp: nil,
73
75
  timestamp: nil
74
76
  )
75
77
  @trace_id = trace_id || SecureRandom.uuid.delete("-")
76
- @span_id = SecureRandom.hex(8)
78
+ @span_id = span_id || SecureRandom.hex(8)
77
79
  @parent_span_id = parent_span_id
78
80
  @sampled = sampled
79
81
  @start_timestamp = start_timestamp || Sentry.utc_now.to_f
80
82
  @timestamp = timestamp
81
83
  @description = description
84
+ @transaction = transaction
82
85
  @op = op
83
86
  @status = status
84
87
  @data = {}
@@ -87,11 +90,8 @@ module Sentry
87
90
 
88
91
  # Finishes the span by adding a timestamp.
89
92
  # @return [self]
90
- def finish
91
- # already finished
92
- return if @timestamp
93
-
94
- @timestamp = Sentry.utc_now.to_f
93
+ def finish(end_timestamp: nil)
94
+ @timestamp = end_timestamp || @timestamp || Sentry.utc_now.to_f
95
95
  self
96
96
  end
97
97
 
@@ -105,10 +105,10 @@ module Sentry
105
105
  end
106
106
 
107
107
  # Generates a W3C Baggage header string for distributed tracing
108
- # from the incoming baggage stored on the transation.
108
+ # from the incoming baggage stored on the transaction.
109
109
  # @return [String, nil]
110
110
  def to_baggage
111
- transaction&.get_baggage&.serialize
111
+ transaction.get_baggage&.serialize
112
112
  end
113
113
 
114
114
  # @return [Hash]
@@ -143,9 +143,8 @@ module Sentry
143
143
  # Starts a child span with given attributes.
144
144
  # @param attributes [Hash] the attributes for the child span.
145
145
  def start_child(**attributes)
146
- attributes = attributes.dup.merge(trace_id: @trace_id, parent_span_id: @span_id, sampled: @sampled)
146
+ attributes = attributes.dup.merge(transaction: @transaction, trace_id: @trace_id, parent_span_id: @span_id, sampled: @sampled)
147
147
  new_span = Span.new(**attributes)
148
- new_span.transaction = transaction
149
148
  new_span.span_recorder = span_recorder
150
149
 
151
150
  if span_recorder
@@ -170,6 +169,10 @@ module Sentry
170
169
  yield(child_span)
171
170
 
172
171
  child_span.finish
172
+ rescue
173
+ child_span.set_http_status(500)
174
+ child_span.finish
175
+ raise
173
176
  end
174
177
 
175
178
  def deep_dup
@@ -25,7 +25,7 @@ module Sentry
25
25
  copied_config.background_worker_threads = 0
26
26
 
27
27
  # user can overwrite some of the configs, with a few exceptions like:
28
- # - capture_exception_frame_locals
28
+ # - include_local_variables
29
29
  # - auto_session_tracking
30
30
  block&.call(copied_config)
31
31
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "sentry/baggage"
4
+ require "sentry/profiler"
4
5
 
5
6
  module Sentry
6
7
  class Transaction < Span
@@ -37,6 +38,10 @@ module Sentry
37
38
  # @return [Baggage, nil]
38
39
  attr_reader :baggage
39
40
 
41
+ # The measurements added to the transaction.
42
+ # @return [Hash]
43
+ attr_reader :measurements
44
+
40
45
  # @deprecated Use Sentry.get_current_hub instead.
41
46
  attr_reader :hub
42
47
 
@@ -50,6 +55,14 @@ module Sentry
50
55
  # @return [Float, nil]
51
56
  attr_reader :effective_sample_rate
52
57
 
58
+ # Additional contexts stored directly on the transaction object.
59
+ # @return [Hash]
60
+ attr_reader :contexts
61
+
62
+ # The Profiler instance for this transaction.
63
+ # @return [Profiler]
64
+ attr_reader :profiler
65
+
53
66
  def initialize(
54
67
  hub:,
55
68
  name: nil,
@@ -58,12 +71,10 @@ module Sentry
58
71
  baggage: nil,
59
72
  **options
60
73
  )
61
- super(**options)
74
+ super(transaction: self, **options)
62
75
 
63
- @name = name
64
- @source = SOURCES.include?(source) ? source.to_sym : :custom
76
+ set_name(name, source: source)
65
77
  @parent_sampled = parent_sampled
66
- @transaction = self
67
78
  @hub = hub
68
79
  @baggage = baggage
69
80
  @configuration = hub.configuration # to be removed
@@ -75,6 +86,9 @@ module Sentry
75
86
  @environment = hub.configuration.environment
76
87
  @dsn = hub.configuration.dsn
77
88
  @effective_sample_rate = nil
89
+ @contexts = {}
90
+ @measurements = {}
91
+ @profiler = Profiler.new(@configuration)
78
92
  init_span_recorder
79
93
  end
80
94
 
@@ -92,16 +106,10 @@ module Sentry
92
106
  return unless hub.configuration.tracing_enabled?
93
107
  return unless sentry_trace
94
108
 
95
- match = SENTRY_TRACE_REGEXP.match(sentry_trace)
96
- return if match.nil?
97
- trace_id, parent_span_id, sampled_flag = match[1..3]
109
+ sentry_trace_data = extract_sentry_trace(sentry_trace)
110
+ return unless sentry_trace_data
98
111
 
99
- parent_sampled =
100
- if sampled_flag.nil?
101
- nil
102
- else
103
- sampled_flag != "0"
104
- end
112
+ trace_id, parent_span_id, parent_sampled = sentry_trace_data
105
113
 
106
114
  baggage = if baggage && !baggage.empty?
107
115
  Baggage.from_incoming_header(baggage)
@@ -124,6 +132,20 @@ module Sentry
124
132
  )
125
133
  end
126
134
 
135
+ # Extract the trace_id, parent_span_id and parent_sampled values from a sentry-trace header.
136
+ #
137
+ # @param sentry_trace [String] the sentry-trace header value from the previous transaction.
138
+ # @return [Array, nil]
139
+ def self.extract_sentry_trace(sentry_trace)
140
+ match = SENTRY_TRACE_REGEXP.match(sentry_trace)
141
+ return nil if match.nil?
142
+
143
+ trace_id, parent_span_id, sampled_flag = match[1..3]
144
+ parent_sampled = sampled_flag.nil? ? nil : sampled_flag != "0"
145
+
146
+ [trace_id, parent_span_id, parent_sampled]
147
+ end
148
+
127
149
  # @return [Hash]
128
150
  def to_hash
129
151
  hash = super
@@ -152,6 +174,15 @@ module Sentry
152
174
  copy
153
175
  end
154
176
 
177
+ # Sets a custom measurement on the transaction.
178
+ # @param name [String] name of the measurement
179
+ # @param value [Float] value of the measurement
180
+ # @param unit [String] unit of the measurement
181
+ # @return [void]
182
+ def set_measurement(name, value, unit = "")
183
+ @measurements[name] = { value: value, unit: unit }
184
+ end
185
+
155
186
  # Sets initial sampling decision of the transaction.
156
187
  # @param sampling_context [Hash] a context Hash that'll be passed to `traces_sampler` (if provided).
157
188
  # @return [void]
@@ -211,7 +242,7 @@ module Sentry
211
242
  # Finishes the transaction's recording and send it to Sentry.
212
243
  # @param hub [Hub] the hub that'll send this transaction. (Deprecated)
213
244
  # @return [TransactionEvent]
214
- def finish(hub: nil)
245
+ def finish(hub: nil, end_timestamp: nil)
215
246
  if hub
216
247
  log_warn(
217
248
  <<~MSG
@@ -223,12 +254,14 @@ module Sentry
223
254
 
224
255
  hub ||= @hub
225
256
 
226
- super() # Span#finish doesn't take arguments
257
+ super(end_timestamp: end_timestamp)
227
258
 
228
259
  if @name.nil?
229
260
  @name = UNLABELD_NAME
230
261
  end
231
262
 
263
+ @profiler.stop
264
+
232
265
  if @sampled
233
266
  event = hub.current_client.event_from_transaction(self)
234
267
  hub.capture_event(event)
@@ -245,6 +278,31 @@ module Sentry
245
278
  @baggage
246
279
  end
247
280
 
281
+ # Set the transaction name directly.
282
+ # Considered internal api since it bypasses the usual scope logic.
283
+ # @param name [String]
284
+ # @param source [Symbol]
285
+ # @return [void]
286
+ def set_name(name, source: :custom)
287
+ @name = name
288
+ @source = SOURCES.include?(source) ? source.to_sym : :custom
289
+ end
290
+
291
+ # Set contexts directly on the transaction.
292
+ # @param key [String, Symbol]
293
+ # @param value [Object]
294
+ # @return [void]
295
+ def set_context(key, value)
296
+ @contexts[key] = value
297
+ end
298
+
299
+ # Start the profiler.
300
+ # @return [void]
301
+ def start_profiler!
302
+ profiler.set_initial_sample_decision(sampled)
303
+ profiler.start
304
+ end
305
+
248
306
  protected
249
307
 
250
308
  def init_span_recorder(limit = 1000)