sentry-ruby 5.4.2 → 5.9.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
@@ -18,7 +18,7 @@ module Sentry
18
18
  Sentry.with_scope do |scope|
19
19
  Sentry.with_session_tracking do
20
20
  scope.clear_breadcrumbs
21
- scope.set_transaction_name(env["PATH_INFO"]) if env["PATH_INFO"]
21
+ scope.set_transaction_name(env["PATH_INFO"], source: :url) if env["PATH_INFO"]
22
22
  scope.set_rack_env(env)
23
23
 
24
24
  transaction = start_transaction(env, scope)
@@ -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)
@@ -63,8 +63,10 @@ module Sentry
63
63
 
64
64
  def start_transaction(env, scope)
65
65
  sentry_trace = env["HTTP_SENTRY_TRACE"]
66
- options = { name: scope.transaction_name, op: transaction_op }
67
- transaction = Sentry::Transaction.from_sentry_trace(sentry_trace, **options) if sentry_trace
66
+ baggage = env["HTTP_BAGGAGE"]
67
+
68
+ options = { name: scope.transaction_name, source: scope.transaction_source, op: transaction_op }
69
+ transaction = Sentry::Transaction.from_sentry_trace(sentry_trace, baggage: baggage, **options) if sentry_trace
68
70
  Sentry.start_transaction(transaction: transaction, custom_sampling_context: { env: env }, **options)
69
71
  end
70
72
 
data/lib/sentry/rake.rb CHANGED
@@ -10,7 +10,7 @@ module Sentry
10
10
  def display_error_message(ex)
11
11
  Sentry.capture_exception(ex) do |scope|
12
12
  task_name = top_level_tasks.join(' ')
13
- scope.set_transaction_name(task_name)
13
+ scope.set_transaction_name(task_name, source: :task)
14
14
  scope.set_tag("rake_task", task_name)
15
15
  end if Sentry.initialized? && !Sentry.configuration.skip_rake_integration
16
16
 
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,18 +29,6 @@ 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
40
33
  return unless Sentry.configuration.breadcrumbs_logger.include?(LOGGER_NAME)
41
34
 
@@ -61,10 +54,16 @@ module Sentry
61
54
  def parsed_commands
62
55
  commands.map do |statement|
63
56
  command, key, *arguments = statement
57
+ command_set = { command: command.to_s.upcase }
58
+ command_set[:key] = key if Utils::EncodingHelper.valid_utf_8?(key)
64
59
 
65
- { command: command.to_s.upcase, key: key }.tap do |command_set|
66
- command_set[:arguments] = arguments.join(" ") if Sentry.configuration.send_default_pii
60
+ if Sentry.configuration.send_default_pii
61
+ command_set[:arguments] = arguments
62
+ .select { |a| Utils::EncodingHelper.valid_utf_8?(a) }
63
+ .join(" ")
67
64
  end
65
+
66
+ command_set
68
67
  end
69
68
  end
70
69
 
@@ -72,19 +71,32 @@ module Sentry
72
71
  "#{host}:#{port}/#{db}"
73
72
  end
74
73
 
75
- module Client
74
+ module OldClientPatch
76
75
  def logging(commands, &block)
77
- Sentry::Redis.new(commands, host, port, db).instrument do
78
- super
79
- end
76
+ Sentry::Redis.new(commands, host, port, db).instrument { super }
77
+ end
78
+ end
79
+
80
+ module GlobalRedisInstrumentation
81
+ def call(command, redis_config)
82
+ Sentry::Redis
83
+ .new([command], redis_config.host, redis_config.port, redis_config.db)
84
+ .instrument { super }
85
+ end
86
+
87
+ def call_pipelined(commands, redis_config)
88
+ Sentry::Redis
89
+ .new(commands, redis_config.host, redis_config.port, redis_config.db)
90
+ .instrument { super }
80
91
  end
81
92
  end
82
93
  end
83
94
  end
84
95
 
85
96
  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)
97
+ if Gem::Version.new(::Redis::VERSION) < Gem::Version.new("5.0")
98
+ Sentry.register_patch(Sentry::Redis::OldClientPatch, ::Redis::Client)
99
+ elsif defined?(RedisClient)
100
+ RedisClient.register(Sentry::Redis::GlobalRedisInstrumentation)
89
101
  end
90
102
  end
data/lib/sentry/scope.rb CHANGED
@@ -7,7 +7,21 @@ module Sentry
7
7
  class Scope
8
8
  include ArgumentCheckingHelper
9
9
 
10
- ATTRIBUTES = [:transaction_names, :contexts, :extra, :tags, :user, :level, :breadcrumbs, :fingerprint, :event_processors, :rack_env, :span, :session]
10
+ ATTRIBUTES = [
11
+ :transaction_names,
12
+ :transaction_sources,
13
+ :contexts,
14
+ :extra,
15
+ :tags,
16
+ :user,
17
+ :level,
18
+ :breadcrumbs,
19
+ :fingerprint,
20
+ :event_processors,
21
+ :rack_env,
22
+ :span,
23
+ :session
24
+ ]
11
25
 
12
26
  attr_reader(*ATTRIBUTES)
13
27
 
@@ -33,6 +47,7 @@ module Sentry
33
47
  event.extra = extra.merge(event.extra)
34
48
  event.contexts = contexts.merge(event.contexts)
35
49
  event.transaction = transaction_name if transaction_name
50
+ event.transaction_info = { source: transaction_source } if transaction_source
36
51
 
37
52
  if span
38
53
  event.contexts[:trace] = span.get_trace_context
@@ -43,8 +58,10 @@ module Sentry
43
58
  event.breadcrumbs = breadcrumbs
44
59
  event.rack_env = rack_env if rack_env
45
60
 
46
- unless @event_processors.empty?
47
- @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|
48
65
  event = processor_block.call(event, hint)
49
66
  end
50
67
  end
@@ -73,7 +90,8 @@ module Sentry
73
90
  copy.extra = extra.deep_dup
74
91
  copy.tags = tags.deep_dup
75
92
  copy.user = user.deep_dup
76
- copy.transaction_names = transaction_names.deep_dup
93
+ copy.transaction_names = transaction_names.dup
94
+ copy.transaction_sources = transaction_sources.dup
77
95
  copy.fingerprint = fingerprint.deep_dup
78
96
  copy.span = span.deep_dup
79
97
  copy.session = session.deep_dup
@@ -90,6 +108,7 @@ module Sentry
90
108
  self.tags = scope.tags
91
109
  self.user = scope.user
92
110
  self.transaction_names = scope.transaction_names
111
+ self.transaction_sources = scope.transaction_sources
93
112
  self.fingerprint = scope.fingerprint
94
113
  self.span = scope.span
95
114
  end
@@ -173,6 +192,10 @@ module Sentry
173
192
  # @return [Hash]
174
193
  def set_contexts(contexts_hash)
175
194
  check_argument_type!(contexts_hash, Hash)
195
+ contexts_hash.values.each do |val|
196
+ check_argument_type!(val, Hash)
197
+ end
198
+
176
199
  @contexts.merge!(contexts_hash) do |key, old, new|
177
200
  old.merge(new)
178
201
  end
@@ -195,8 +218,9 @@ module Sentry
195
218
  # The "transaction" here does not refer to `Transaction` objects.
196
219
  # @param transaction_name [String]
197
220
  # @return [void]
198
- def set_transaction_name(transaction_name)
221
+ def set_transaction_name(transaction_name, source: :custom)
199
222
  @transaction_names << transaction_name
223
+ @transaction_sources << source
200
224
  end
201
225
 
202
226
  # Sets the currently active session on the scope.
@@ -213,6 +237,13 @@ module Sentry
213
237
  @transaction_names.last
214
238
  end
215
239
 
240
+ # Returns current transaction source.
241
+ # The "transaction" here does not refer to `Transaction` objects.
242
+ # @return [String, nil]
243
+ def transaction_source
244
+ @transaction_sources.last
245
+ end
246
+
216
247
  # Returns the associated Transaction object.
217
248
  # @return [Transaction, nil]
218
249
  def get_transaction
@@ -256,6 +287,7 @@ module Sentry
256
287
  @level = :error
257
288
  @fingerprint = []
258
289
  @transaction_names = []
290
+ @transaction_sources = []
259
291
  @event_processors = []
260
292
  @rack_env = {}
261
293
  @span = nil
@@ -277,7 +309,8 @@ module Sentry
277
309
  name: uname[:sysname] || RbConfig::CONFIG["host_os"],
278
310
  version: uname[:version],
279
311
  build: uname[:release],
280
- kernel_version: uname[:version]
312
+ kernel_version: uname[:version],
313
+ machine: uname[:machine]
281
314
  }
282
315
  end
283
316
  end
@@ -289,6 +322,22 @@ module Sentry
289
322
  version: RUBY_DESCRIPTION || Sentry.sys_command("ruby -v")
290
323
  }
291
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
292
341
  end
293
342
 
294
343
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Sentry
4
4
  class Session
5
- attr_reader :started, :status
5
+ attr_reader :started, :status, :aggregation_key
6
6
 
7
7
  # TODO-neel add :crashed after adding handled mechanism
8
8
  STATUSES = %i(ok errored exited)
@@ -11,6 +11,10 @@ module Sentry
11
11
  def initialize
12
12
  @started = Sentry.utc_now
13
13
  @status = :ok
14
+
15
+ # truncate seconds from the timestamp since we only care about
16
+ # minute level granularity for aggregation
17
+ @aggregation_key = Time.utc(@started.year, @started.month, @started.day, @started.hour, @started.min)
14
18
  end
15
19
 
16
20
  # TODO-neel add :crashed after adding handled mechanism
@@ -22,12 +26,6 @@ module Sentry
22
26
  @status = :exited if @status == :ok
23
27
  end
24
28
 
25
- # truncate seconds from the timestamp since we only care about
26
- # minute level granularity for aggregation
27
- def aggregation_key
28
- Time.utc(started.year, started.month, started.day, started.hour, started.min)
29
- end
30
-
31
29
  def deep_dup
32
30
  dup
33
31
  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
 
@@ -104,6 +104,13 @@ module Sentry
104
104
  "#{@trace_id}-#{@span_id}-#{sampled_flag}"
105
105
  end
106
106
 
107
+ # Generates a W3C Baggage header string for distributed tracing
108
+ # from the incoming baggage stored on the transaction.
109
+ # @return [String, nil]
110
+ def to_baggage
111
+ transaction.get_baggage&.serialize
112
+ end
113
+
107
114
  # @return [Hash]
108
115
  def to_hash
109
116
  {
@@ -136,9 +143,8 @@ module Sentry
136
143
  # Starts a child span with given attributes.
137
144
  # @param attributes [Hash] the attributes for the child span.
138
145
  def start_child(**attributes)
139
- 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)
140
147
  new_span = Span.new(**attributes)
141
- new_span.transaction = transaction
142
148
  new_span.span_recorder = span_recorder
143
149
 
144
150
  if span_recorder
@@ -163,6 +169,10 @@ module Sentry
163
169
  yield(child_span)
164
170
 
165
171
  child_span.finish
172
+ rescue
173
+ child_span.set_http_status(500)
174
+ child_span.finish
175
+ raise
166
176
  end
167
177
 
168
178
  def deep_dup
@@ -25,12 +25,13 @@ 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
 
32
32
  test_client = Sentry::Client.new(copied_config)
33
33
  Sentry.get_current_hub.bind_client(test_client)
34
+ Sentry.get_current_scope.clear
34
35
  end
35
36
 
36
37
  # Clears all stored events and envelopes.
@@ -41,6 +42,7 @@ module Sentry
41
42
 
42
43
  sentry_transport.events = []
43
44
  sentry_transport.envelopes = []
45
+ Sentry.get_current_scope.clear
44
46
  end
45
47
 
46
48
  # @return [Transport]