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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +152 -2
- data/lib/koderift/rails/breadcrumb.rb +70 -0
- data/lib/koderift/rails/capture.rb +514 -0
- data/lib/koderift/rails/configuration.rb +29 -0
- data/lib/koderift/rails/controller.rb +21 -0
- data/lib/koderift/rails/event.rb +27 -0
- data/lib/koderift/rails/instrumentation.rb +64 -19
- data/lib/koderift/rails/lograge_config.rb +7 -2
- data/lib/koderift/rails/railtie.rb +14 -0
- data/lib/koderift/rails/rake_integration.rb +43 -0
- data/lib/koderift/rails/scope.rb +163 -0
- data/lib/koderift/rails/sidekiq_middleware.rb +24 -1
- data/lib/koderift/rails/tracing.rb +195 -0
- data/lib/koderift/rails/version.rb +1 -1
- data/lib/koderift/rails.rb +186 -0
- metadata +7 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
49
|
+
format_frames(exception&.backtrace)
|
|
50
|
+
end
|
|
50
51
|
|
|
51
|
-
|
|
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
|
|
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
|