koderift-rails 1.3.4 → 2.1.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
@@ -3,6 +3,18 @@
3
3
  module Koderift
4
4
  module Rails
5
5
  module RumHelper
6
+ MISSING_RUM_TOKEN_WARNING =
7
+ '[Koderift] Browser RUM is off: koderift_rum_meta_tag renders nothing until ' \
8
+ 'config.rum_token is set. Since koderift-rails 2.1 the tag no longer publishes ' \
9
+ 'config.project_token, which is a secret. Copy the public RUM token (it starts ' \
10
+ "with rum_) from your project's Log Analytics setup page in Koderift, or set " \
11
+ 'config.rum_enabled = false.'
12
+
13
+ SECRET_RUM_TOKEN_WARNING =
14
+ '[Koderift] Browser RUM is off: config.rum_token is set to your project token, ' \
15
+ 'which is a secret and must not appear in page HTML. Use the public RUM token ' \
16
+ "(it starts with rum_) from your project's Log Analytics setup page in Koderift."
17
+
6
18
  # Renders the meta tag that rum.js reads for initialisation.
7
19
  # Place once inside <head> in your application layout.
8
20
  #
@@ -11,24 +23,68 @@ module Koderift
11
23
  #
12
24
  # Renders:
13
25
  # <meta name="koderift-rum-token"
14
- # content="abc123"
26
+ # content="rum_abc123"
15
27
  # data-rum-sampling-rate="0.1"
16
28
  # data-environment="production">
17
29
  #
18
- # Returns an empty string when RUM is disabled or no token is configured.
30
+ # Publishes config.rum_token only, never config.project_token. Returns an
31
+ # empty string when RUM is disabled or there is no publishable token.
19
32
  def koderift_rum_meta_tag
20
33
  config = Koderift::Rails.configuration
21
- return ''.html_safe unless config.rum_enabled && config.project_token.present?
34
+ return ''.html_safe unless config.rum_enabled
35
+
36
+ token = RumHelper.publishable_rum_token(config)
37
+ return ''.html_safe unless token
22
38
 
23
39
  tag.meta(
24
40
  name: 'koderift-rum-token',
25
- content: config.project_token,
41
+ content: token,
26
42
  data: {
27
43
  rum_sampling_rate: config.rum_sampling_rate.to_f,
28
44
  environment: (defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : 'production')
29
45
  }
30
46
  )
31
47
  end
48
+
49
+ class << self
50
+ # The token the meta tag may publish, or nil. Up to 2.0 the tag published
51
+ # project_token, the log agent's ingest secret. It never does now, and a
52
+ # rum_token equal to it is refused for the same reason. There is
53
+ # deliberately no fallback: an app that set up RUM before 2.1 and has
54
+ # not set rum_token gets one warning per process instead.
55
+ def publishable_rum_token(config)
56
+ token = config.rum_token.to_s
57
+ secret = config.project_token.to_s
58
+
59
+ if token.empty?
60
+ warn_once(MISSING_RUM_TOKEN_WARNING) unless secret.empty?
61
+ return nil
62
+ end
63
+
64
+ if token == secret
65
+ warn_once(SECRET_RUM_TOKEN_WARNING)
66
+ return nil
67
+ end
68
+
69
+ token
70
+ end
71
+
72
+ def reset_warnings!
73
+ @warned = false
74
+ end
75
+
76
+ private
77
+
78
+ def warn_once(message)
79
+ return if @warned
80
+
81
+ @warned = true
82
+ logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
83
+ logger&.warn(message)
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ end
32
88
  end
33
89
  end
34
90
  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,