sentry-ruby 5.5.0 → 5.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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)