koderift-rails 1.3.4 → 2.0.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.
@@ -10,11 +10,28 @@ module Koderift
10
10
  # partial render times, SQL query times, and breadcrumbs.
11
11
  # Returns a hash of captured data to be merged into the log payload.
12
12
  def self.capture
13
+ state = start_capture
14
+ begin
15
+ yield
16
+ ensure
17
+ stop_capture(state)
18
+ end
19
+ build_result(state)
20
+ end
21
+
22
+ # capture's three phases, exposed separately so a unit of work that is
23
+ # NOT a block runs through the same subscriptions rather than a parallel
24
+ # implementation. Tracing::Transaction is that caller: it has to be
25
+ # started and finished by two separate calls to mirror Sentry's
26
+ # start_transaction / finish, which a block-shaped API cannot express.
27
+ def self.start_capture
28
+ config = Koderift::Rails.configuration
13
29
  partials = []
14
30
  queries = []
15
31
  search_queries = []
16
32
  breadcrumbs = []
17
- config = Koderift::Rails.configuration
33
+ custom_spans = []
34
+ external_calls = []
18
35
 
19
36
  partial_sub = ActiveSupport::Notifications.subscribe('render_partial.action_view') do |*args|
20
37
  event = ActiveSupport::Notifications::Event.new(*args)
@@ -73,36 +90,54 @@ module Koderift
73
90
  search_queries << { index: 'multi_search', duration_ms: duration }
74
91
  end
75
92
 
76
- external_calls = []
93
+ # Published for the same reason external_calls is: code that runs
94
+ # somewhere else entirely — the Net::HTTP patch, Koderift.add_breadcrumb,
95
+ # Koderift.with_child_span — has no other way to find the collection
96
+ # belonging to the unit of work currently in flight on this thread.
97
+ Thread.current[:koderift_breadcrumbs] = breadcrumbs
98
+ Thread.current[:koderift_custom_spans] = custom_spans
77
99
  Thread.current[:koderift_external_calls] = external_calls
78
100
  Thread.current[:koderift_external_config] = config
79
101
 
80
- begin
81
- yield
82
- ensure
83
- ActiveSupport::Notifications.unsubscribe(partial_sub)
84
- ActiveSupport::Notifications.unsubscribe(query_sub)
85
- ActiveSupport::Notifications.unsubscribe(search_sub)
86
- ActiveSupport::Notifications.unsubscribe(multi_search_sub)
87
- Thread.current[:koderift_external_calls] = nil
88
- Thread.current[:koderift_external_config] = nil
102
+ {
103
+ config: config,
104
+ partials: partials,
105
+ queries: queries,
106
+ search_queries: search_queries,
107
+ breadcrumbs: breadcrumbs,
108
+ custom_spans: custom_spans,
109
+ external_calls: external_calls,
110
+ subscriptions: [partial_sub, query_sub, search_sub, multi_search_sub]
111
+ }
112
+ end
113
+
114
+ def self.stop_capture(state)
115
+ state[:subscriptions].each do |sub|
116
+ ActiveSupport::Notifications.unsubscribe(sub)
89
117
  end
118
+ Thread.current[:koderift_breadcrumbs] = nil
119
+ Thread.current[:koderift_custom_spans] = nil
120
+ Thread.current[:koderift_external_calls] = nil
121
+ Thread.current[:koderift_external_config] = nil
122
+ state
123
+ end
90
124
 
125
+ def self.build_result(state)
91
126
  spans = []
92
127
 
93
- partials.each_with_index do |p, i|
128
+ state[:partials].each_with_index do |p, i|
94
129
  spans << { type: 'partial', name: p[:partial], duration_ms: p[:duration_ms], sequence: i }
95
130
  end
96
131
 
97
- queries.each_with_index do |q, i|
132
+ state[:queries].each_with_index do |q, i|
98
133
  spans << { type: 'sql', name: q[:sql], duration_ms: q[:duration_ms], sequence: i }
99
134
  end
100
135
 
101
- search_queries.each_with_index do |s, i|
136
+ state[:search_queries].each_with_index do |s, i|
102
137
  spans << { type: 'search', name: s[:index], duration_ms: s[:duration_ms], sequence: i }
103
138
  end
104
139
 
105
- external_calls.each_with_index do |c, i|
140
+ state[:external_calls].each_with_index do |c, i|
106
141
  spans << {
107
142
  type: 'http',
108
143
  name: "#{c[:host]}#{c[:endpoint]}",
@@ -112,12 +147,22 @@ module Koderift
112
147
  }
113
148
  end
114
149
 
150
+ state[:custom_spans].each_with_index do |c, i|
151
+ spans << {
152
+ type: 'custom',
153
+ name: c[:name],
154
+ duration_ms: c[:duration_ms],
155
+ detail: c[:detail],
156
+ sequence: i
157
+ }.compact
158
+ end
159
+
115
160
  {
116
161
  spans: spans,
117
- query_count: queries.size,
118
- partial_count: partials.size,
119
- search_count: search_queries.size,
120
- breadcrumbs: breadcrumbs.last(config.max_breadcrumbs)
162
+ query_count: state[:queries].size,
163
+ partial_count: state[:partials].size,
164
+ search_count: state[:search_queries].size,
165
+ breadcrumbs: state[:breadcrumbs].last(state[:config].max_breadcrumbs)
121
166
  }
122
167
  end
123
168
 
@@ -46,9 +46,14 @@ module Koderift
46
46
  end
47
47
 
48
48
  def self.format_backtrace(exception)
49
- return nil unless exception&.backtrace&.any?
49
+ format_frames(exception&.backtrace)
50
+ end
50
51
 
51
- backtrace = exception.backtrace
52
+ # Split out from format_backtrace so a caller holding a raw backtrace
53
+ # array -- Capture, when given an explicit `backtrace:` -- gets the same
54
+ # frame selection instead of a second, subtly different implementation.
55
+ def self.format_frames(backtrace)
56
+ return nil unless backtrace.is_a?(Array) && backtrace.any?
52
57
 
53
58
  leading = backtrace.first(LEADING_FRAMES)
54
59
 
@@ -34,6 +34,20 @@ module Koderift
34
34
  end
35
35
  end
36
36
 
37
+ # Capture uncaught rake task failures. Rake is loaded before the Rails
38
+ # environment under `bundle exec rake` / `bin/rails <task>`, so the
39
+ # constant is present by the time initializers run; under a web server it
40
+ # is not, and the guard makes this a no-cost skip.
41
+ initializer 'koderift.rake_integration', after: :load_config_initializers do
42
+ next unless Koderift::Rails.configuration.enabled
43
+ next unless defined?(::Rake::Application)
44
+
45
+ require 'koderift/rails/rake_integration'
46
+ unless ::Rake::Application.include?(Koderift::Rails::RakeIntegration)
47
+ ::Rake::Application.prepend(Koderift::Rails::RakeIntegration)
48
+ end
49
+ end
50
+
37
51
  # Register Sidekiq server middleware lazily when Sidekiq is present.
38
52
  initializer 'koderift.sidekiq_middleware', after: :load_config_initializers do
39
53
  next unless Koderift::Rails.configuration.enabled
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koderift
4
+ module Rails
5
+ # Captures uncaught rake task failures.
6
+ #
7
+ # This closes the largest blind spot in the product for scheduled work. A
8
+ # rake task that raises writes its backtrace to STDERR, which under cron
9
+ # goes to a mail spool or /dev/null — never to production.log — so the log
10
+ # agent has never seen it and Koderift has never reported it. The tasks
11
+ # most likely to fail unnoticed are exactly the ones nobody has touched in
12
+ # a year.
13
+ #
14
+ # Rake::Application#display_error_message is the hook Sentry uses for the
15
+ # same purpose. We capture and then call super, so Rake's own error output
16
+ # is completely unchanged — anything already scraping that output, or a
17
+ # human reading the cron mail, sees exactly what it saw before.
18
+ module RakeIntegration
19
+ def display_error_message(exception)
20
+ Koderift::Rails::RakeIntegration.report(exception)
21
+ super
22
+ end
23
+
24
+ # A clean `exit` inside a task reaches this hook as a SystemExit. It is
25
+ # the task finishing, not failing, and reporting it would file an error
26
+ # for every well-behaved task that exits explicitly.
27
+ def self.report(exception)
28
+ return if exception.is_a?(SystemExit)
29
+
30
+ Koderift.capture_exception(exception, component: task_name, action: 'rake')
31
+ rescue StandardError
32
+ nil
33
+ end
34
+
35
+ def self.task_name
36
+ name = ::Rake.application.top_level_tasks.first.to_s
37
+ name.empty? ? 'rake' : name
38
+ rescue StandardError
39
+ 'rake'
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'koderift/rails/breadcrumb'
4
+
5
+ module Koderift
6
+ module Rails
7
+ # Sentry-compatible scope: ambient user/tags/extras/contexts merged into
8
+ # every capture made while it is current.
9
+ #
10
+ # The stack is thread-local, and it is RESET at the end of every request and
11
+ # Sidekiq job (see Controller and SidekiqMiddleware). That reset is not
12
+ # housekeeping: Puma and Sidekiq both reuse threads, so without it a tag set
13
+ # for one user's request would silently attach to the next request served by
14
+ # that thread.
15
+ class Scope
16
+ STACK_KEY = :koderift_scope_stack
17
+
18
+ attr_reader :user, :tags, :extras, :contexts
19
+ attr_accessor :level, :fingerprint, :component, :action
20
+
21
+ def initialize
22
+ @user = {}
23
+ @tags = {}
24
+ @extras = {}
25
+ @contexts = {}
26
+ @level = nil
27
+ @fingerprint = nil
28
+ @component = nil
29
+ @action = nil
30
+ end
31
+
32
+ class << self
33
+ def stack
34
+ Thread.current[STACK_KEY] ||= [new]
35
+ end
36
+
37
+ def current
38
+ stack.last
39
+ end
40
+
41
+ def push
42
+ stack.push(current.dup)
43
+ current
44
+ end
45
+
46
+ def pop
47
+ stack.pop if stack.size > 1
48
+ current
49
+ end
50
+
51
+ def reset!
52
+ Thread.current[STACK_KEY] = nil
53
+ end
54
+
55
+ # Runs `block` against a temporary copy of the current scope before
56
+ # yielding, so `capture_exception(e) { |scope| scope.set_tags(...) }`
57
+ # cannot leak those tags into later captures on the same thread.
58
+ def with_temporary(block)
59
+ return yield unless block
60
+
61
+ push
62
+ begin
63
+ block.call(current)
64
+ yield
65
+ ensure
66
+ pop
67
+ end
68
+ end
69
+ end
70
+
71
+ def set_user(hash)
72
+ @user = stringify(hash)
73
+ self
74
+ end
75
+
76
+ def set_tags(hash)
77
+ @tags.merge!(stringify(hash))
78
+ self
79
+ end
80
+
81
+ def set_tag(key, value)
82
+ @tags[key.to_s] = value
83
+ self
84
+ end
85
+
86
+ def set_extras(hash)
87
+ @extras.merge!(stringify(hash))
88
+ self
89
+ end
90
+
91
+ def set_extra(key, value)
92
+ @extras[key.to_s] = value
93
+ self
94
+ end
95
+
96
+ def set_context(key, value)
97
+ @contexts[key.to_s] = value
98
+ self
99
+ end
100
+
101
+ def set_contexts(hash)
102
+ @contexts.merge!(stringify(hash))
103
+ self
104
+ end
105
+
106
+ def set_level(level)
107
+ @level = level
108
+ self
109
+ end
110
+
111
+ def set_fingerprint(fingerprint)
112
+ @fingerprint = fingerprint
113
+ self
114
+ end
115
+
116
+ # Sentry's transaction name is "Controller#action". Koderift stores those
117
+ # as two separate columns and groups errors on them, so split rather than
118
+ # keep the joined string.
119
+ def set_transaction_name(name)
120
+ component, _, action = name.to_s.partition('#')
121
+ @component = component.empty? ? nil : component
122
+ @action = action.empty? ? nil : action
123
+ self
124
+ end
125
+
126
+ def add_breadcrumb(crumb)
127
+ Breadcrumbs.add(crumb)
128
+ self
129
+ end
130
+
131
+ def clear_breadcrumbs
132
+ Breadcrumbs.clear
133
+ self
134
+ end
135
+
136
+ def clear
137
+ initialize
138
+ self
139
+ end
140
+
141
+ def dup
142
+ copy = self.class.new
143
+ copy.set_user(@user)
144
+ copy.set_tags(@tags)
145
+ copy.set_extras(@extras)
146
+ copy.set_contexts(@contexts)
147
+ copy.level = @level
148
+ copy.fingerprint = @fingerprint
149
+ copy.component = @component
150
+ copy.action = @action
151
+ copy
152
+ end
153
+
154
+ private
155
+
156
+ def stringify(hash)
157
+ return {} unless hash.respond_to?(:each_pair)
158
+
159
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
160
+ end
161
+ end
162
+ end
163
+ end
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'securerandom'
5
5
  require 'koderift/rails/instrumentation'
6
+ require 'koderift/rails/scope'
6
7
 
7
8
  module Koderift
8
9
  module Rails
@@ -19,6 +20,16 @@ module Koderift
19
20
  trace_id = SecureRandom.uuid if trace_id.empty?
20
21
  Thread.current[:koderift_trace_id] = trace_id
21
22
 
23
+ # Ambient context for manual captures made anywhere inside the job.
24
+ # The jid stands in for a request id: it is what correlates a capture
25
+ # with the job's own log line, and what carries this job's spans into
26
+ # log_spans (IngestLogBatchJob drops spans on a row with no request id).
27
+ Thread.current[:koderift_context] = {
28
+ component: job_name(job),
29
+ action: 'perform',
30
+ request_id: job['jid'].to_s
31
+ }
32
+
22
33
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
34
 
24
35
  begin
@@ -34,11 +45,23 @@ module Koderift
34
45
  raise
35
46
  ensure
36
47
  Thread.current[:koderift_trace_id] = nil
48
+ Thread.current[:koderift_context] = nil
49
+ # Sidekiq reuses threads across jobs; see the same reset in Controller.
50
+ Koderift::Rails::Scope.reset!
37
51
  end
38
52
  end
39
53
 
40
54
  private
41
55
 
56
+ # The name Sidekiq's own JobLogger logs the job under. For ActiveJob,
57
+ # job['class'] is the adapter's wrapper (Sidekiq::ActiveJob::Wrapper,
58
+ # ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper) for every job, and
59
+ # job['wrapped'] is the job itself. Matching Sidekiq also lets the agent
60
+ # pair this line with Sidekiq's "done" line for the same attempt.
61
+ def job_name(job)
62
+ (job['wrapped'] || job['class']).to_s
63
+ end
64
+
42
65
  def emit_job_log(_worker, job, queue, result, duration_ms, trace_id, error:)
43
66
  config = Koderift::Rails.configuration
44
67
 
@@ -51,7 +74,7 @@ module Koderift
51
74
  end
52
75
 
53
76
  payload = {
54
- job_class: job['class'].to_s,
77
+ job_class: job_name(job),
55
78
  jid: job['jid'].to_s,
56
79
  queue: queue.to_s,
57
80
  status: error ? 500 : 200,
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'koderift/rails/instrumentation'
6
+ require 'koderift/rails/capture'
7
+
8
+ module Koderift
9
+ module Rails
10
+ # Sentry-compatible custom instrumentation, backed by the span pipeline the
11
+ # gem already has. A child span becomes a `custom` span in log_spans and
12
+ # renders in the APM trace next to the SQL, partial and HTTP spans.
13
+ module Tracing
14
+ TRANSACTION_KEY = :koderift_transaction
15
+ MAX_SPAN_NAME = 2_000
16
+
17
+ # Handed to a with_child_span block. set_data and set_description are
18
+ # real: they shape the span's detail and name in the emitted trace.
19
+ class ChildSpan
20
+ attr_reader :op, :description
21
+
22
+ def initialize(op: nil, description: nil)
23
+ @op = op
24
+ @description = description
25
+ @data = {}
26
+ end
27
+
28
+ def set_data(key, value)
29
+ @data[key.to_s] = value
30
+ self
31
+ end
32
+
33
+ def set_description(value)
34
+ @description = value
35
+ self
36
+ end
37
+
38
+ def name
39
+ [@description, @op].map(&:to_s).reject(&:empty?).first || 'custom'
40
+ end
41
+
42
+ def detail
43
+ payload = @data.dup
44
+ payload['op'] = @op.to_s unless @op.to_s.empty?
45
+ payload.empty? ? nil : JSON.generate(payload)
46
+ rescue StandardError
47
+ nil
48
+ end
49
+ end
50
+
51
+ # A unit of work that is neither an HTTP request nor a Sidekiq job — the
52
+ # nightly rake task case. It runs through Instrumentation's subscriptions
53
+ # exactly as those two do, so its SQL, HTTP and custom spans reach the
54
+ # APM trace view.
55
+ class Transaction
56
+ attr_reader :name, :op, :request_id, :trace_id
57
+
58
+ def initialize(name:, op: nil)
59
+ @name = name.to_s
60
+ @op = op
61
+ @finished = false
62
+
63
+ # A transaction MUST carry a request_id.
64
+ # IngestLogBatchJob#build_span_rows does `next if request_id.blank?`,
65
+ # so without one every span collected here is dropped before it
66
+ # reaches log_spans — the trace would arrive empty, with no error
67
+ # anywhere to explain why.
68
+ @request_id = SecureRandom.uuid
69
+ @trace_id = Thread.current[:koderift_trace_id] || SecureRandom.uuid
70
+
71
+ @previous_trace_id = Thread.current[:koderift_trace_id]
72
+ @previous_context = Thread.current[Capture::CONTEXT_KEY]
73
+
74
+ Thread.current[:koderift_trace_id] = @trace_id
75
+ Thread.current[Capture::CONTEXT_KEY] = {
76
+ component: component,
77
+ action: action,
78
+ request_id: @request_id
79
+ }
80
+
81
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ @state = Instrumentation.start_capture
83
+ end
84
+
85
+ def finished?
86
+ @finished
87
+ end
88
+
89
+ def finish
90
+ return nil if @finished
91
+
92
+ @finished = true
93
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
94
+
95
+ Instrumentation.stop_capture(@state)
96
+ result = Instrumentation.build_result(@state)
97
+
98
+ Capture.transaction(
99
+ component: component,
100
+ action: action,
101
+ message: "Completed in #{(duration_ms / 1000.0).round(1)}s",
102
+ duration_ms: duration_ms,
103
+ request_id: @request_id,
104
+ trace_id: @trace_id,
105
+ result: result
106
+ )
107
+ rescue StandardError
108
+ nil
109
+ ensure
110
+ Thread.current[:koderift_trace_id] = @previous_trace_id
111
+ Thread.current[Capture::CONTEXT_KEY] = @previous_context
112
+ Tracing.clear_current(self)
113
+ end
114
+
115
+ def component
116
+ part = @name.split('#').first.to_s
117
+ part.empty? ? 'transaction' : part
118
+ end
119
+
120
+ def action
121
+ part = @name.split('#')[1].to_s
122
+ return part unless part.empty?
123
+
124
+ op = @op.to_s
125
+ op.empty? ? 'call' : op
126
+ end
127
+ end
128
+
129
+ class << self
130
+ def with_child_span(op: nil, description: nil)
131
+ span = ChildSpan.new(op: op, description: description)
132
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
133
+ begin
134
+ yield span
135
+ ensure
136
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
137
+ record(span, elapsed)
138
+ end
139
+ end
140
+
141
+ # Returns the Transaction when called without a block, so
142
+ # `txn = Koderift.start_transaction(name: '...')` / `txn.finish` works
143
+ # as it does in Sentry. PREFER the block form: it finishes in an ensure,
144
+ # and an abandoned transaction leaves four ActiveSupport::Notifications
145
+ # subscribers attached to the process. Starting a new transaction
146
+ # finishes an abandoned one on the same thread, which bounds that leak
147
+ # to one, but does not make it free.
148
+ def start_transaction(name: nil, op: nil)
149
+ previous = Thread.current[TRANSACTION_KEY]
150
+ previous.finish if previous.is_a?(Transaction) && !previous.finished?
151
+
152
+ transaction = Transaction.new(name: name || op.to_s, op: op)
153
+ Thread.current[TRANSACTION_KEY] = transaction
154
+ return transaction unless block_given?
155
+
156
+ begin
157
+ yield transaction
158
+ ensure
159
+ transaction.finish
160
+ end
161
+ end
162
+
163
+ def current_transaction
164
+ Thread.current[TRANSACTION_KEY]
165
+ end
166
+
167
+ def clear_current(transaction)
168
+ return unless Thread.current[TRANSACTION_KEY].equal?(transaction)
169
+
170
+ Thread.current[TRANSACTION_KEY] = nil
171
+ end
172
+
173
+ private
174
+
175
+ # Outside a captured unit of work there is no array to append to, so the
176
+ # span is simply not recorded. That is inertness for want of a place to
177
+ # put it, not a stub: inside a request, job or transaction the very same
178
+ # call produces a real span.
179
+ def record(span, duration_ms)
180
+ spans = Thread.current[:koderift_custom_spans]
181
+ return nil unless spans.is_a?(Array)
182
+
183
+ name = span.name.to_s
184
+ spans << {
185
+ name: name.length > MAX_SPAN_NAME ? name[0, MAX_SPAN_NAME] : name,
186
+ duration_ms: duration_ms,
187
+ detail: span.detail
188
+ }
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Koderift
4
4
  module Rails
5
- VERSION = '1.3.4'
5
+ VERSION = '2.0.0'
6
6
  end
7
7
  end