event_timeline 0.2.1 → 0.3.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/README.md +4 -1
- data/lib/event_timeline/call_tracker.rb +94 -50
- data/lib/event_timeline/rotation_service.rb +0 -33
- data/lib/event_timeline/value_inspector.rb +10 -1
- data/lib/event_timeline/version.rb +1 -1
- metadata +3 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9dec56c4ce296fc94cb66f0065008565e072323dcd5b0dc4dd6f1bb719bf55f5
|
|
4
|
+
data.tar.gz: df8f292526aab228bef0084b8e455c6d002dd3b0a971ac328ca112e077f1e04b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ed301440c1362f9a14ab63e545618b8ed29710e9f7bd4bc5d02de7e1cfa426cdfc412d527b49926ed2fb153ec9fb8ef18f0d1bdd1197c218c7b674815724d70
|
|
7
|
+
data.tar.gz: 4cc08a61b6de7c4d3b1312b136b387eb43935ed38aeb23fbf4fec482f705afe04fc73c09e1d235591c11727487a09cbe766a101e83b41729a3406e8621f2d2bb
|
data/README.md
CHANGED
|
@@ -77,6 +77,8 @@ EventTimeline::CallTracker.install! # start tracking
|
|
|
77
77
|
EventTimeline::CallTracker.installed? # check status
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
Methods are attached when their class loads, so configure `watch` before boot. To pick up paths added later, run `uninstall!` then `install!`.
|
|
81
|
+
|
|
80
82
|
## Custom correlation IDs
|
|
81
83
|
|
|
82
84
|
For background jobs or anything outside a request:
|
|
@@ -88,7 +90,8 @@ EventTimeline::CurrentCorrelation.id = "import-job-#{job.id}"
|
|
|
88
90
|
## Limitations
|
|
89
91
|
|
|
90
92
|
- Only tracks Ruby method calls (not SQL queries or HTTP calls)
|
|
91
|
-
-
|
|
93
|
+
- Code outside watched files costs nothing
|
|
94
|
+
- Methods defined at runtime, after their class loaded, are not traced
|
|
92
95
|
- Large values get truncated
|
|
93
96
|
|
|
94
97
|
## License
|
|
@@ -4,54 +4,55 @@ module EventTimeline
|
|
|
4
4
|
class CallTracker
|
|
5
5
|
THREAD_KEY_METHOD_STACK = :event_timeline_method_stack
|
|
6
6
|
THREAD_KEY_EVENT_BUFFER = :event_timeline_event_buffer
|
|
7
|
+
THREAD_KEY_EVENT_SEQUENCE = :event_timeline_event_sequence
|
|
7
8
|
|
|
8
9
|
class << self
|
|
9
|
-
attr_reader :call_trace, :return_trace
|
|
10
|
-
|
|
11
10
|
def install!
|
|
12
11
|
return if installed?
|
|
13
12
|
|
|
14
|
-
@
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
@traces = {}
|
|
14
|
+
ObjectSpace.each_object(Module) do |mod|
|
|
15
|
+
attach(mod) unless deprecated_constant?(mod) || mod.singleton_class?
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
Rails.logger.warn "EventTimeline could not attach to #{mod.name}: #{e.message}"
|
|
18
|
+
end
|
|
19
|
+
register_autoload_hooks
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def uninstall!
|
|
22
|
-
@
|
|
23
|
-
@
|
|
24
|
-
@call_trace = nil
|
|
25
|
-
@return_trace = nil
|
|
23
|
+
@traces&.each_value { |traces| traces.each(&:disable) }
|
|
24
|
+
@traces = nil
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
def installed?
|
|
29
|
-
|
|
28
|
+
!@traces.nil?
|
|
30
29
|
end
|
|
31
30
|
|
|
32
|
-
def flush_events(
|
|
31
|
+
def flush_events(_correlation_id)
|
|
33
32
|
buffer = thread_event_buffer
|
|
34
33
|
return if buffer.empty?
|
|
35
34
|
|
|
36
|
-
buffer_size = buffer.size
|
|
37
35
|
Session.insert_all(buffer)
|
|
38
|
-
|
|
39
|
-
RotationService.enforce_correlation_limit(correlation_id, buffer_size)
|
|
40
36
|
RotationService.cleanup_if_needed
|
|
41
37
|
rescue StandardError => e
|
|
42
38
|
Rails.logger.error "EventTimeline flush failed: #{e.message}" if defined?(Rails.logger)
|
|
39
|
+
ensure
|
|
40
|
+
Thread.current[THREAD_KEY_EVENT_BUFFER] = []
|
|
41
|
+
Thread.current[THREAD_KEY_EVENT_SEQUENCE] = nil
|
|
43
42
|
end
|
|
44
43
|
|
|
45
44
|
def cleanup_thread_state(correlation_id)
|
|
46
45
|
thread_method_stack.delete(correlation_id)
|
|
47
46
|
Thread.current[THREAD_KEY_EVENT_BUFFER] = []
|
|
47
|
+
Thread.current[THREAD_KEY_EVENT_SEQUENCE] = nil
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def record_exception(exception, correlation_id)
|
|
51
51
|
backtrace = clean_backtrace(exception.backtrace || [])
|
|
52
52
|
source_location = extract_source_location(backtrace)
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
# Bypasses the cap: the exception is the event a capped request most needs.
|
|
55
|
+
thread_event_buffer << {
|
|
55
56
|
name: "EXCEPTION: #{exception.class.name}",
|
|
56
57
|
severity: 'error',
|
|
57
58
|
category: 'exception',
|
|
@@ -65,13 +66,56 @@ module EventTimeline
|
|
|
65
66
|
},
|
|
66
67
|
correlation_id: correlation_id,
|
|
67
68
|
occurred_at: Time.current
|
|
68
|
-
|
|
69
|
+
}
|
|
69
70
|
rescue StandardError => e
|
|
70
71
|
Rails.logger.error "EventTimeline exception recording failed: #{e.message}" if defined?(Rails.logger)
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
private
|
|
74
75
|
|
|
76
|
+
# Classes loaded or reloaded after boot are attached and detached as Zeitwerk loads them.
|
|
77
|
+
def register_autoload_hooks
|
|
78
|
+
return if @autoload_hooks_registered || !defined?(Rails.autoloaders)
|
|
79
|
+
|
|
80
|
+
@autoload_hooks_registered = true
|
|
81
|
+
Rails.autoloaders.main.on_load do |_cpath, value, _abspath|
|
|
82
|
+
attach(value) if installed? && value.is_a?(Module)
|
|
83
|
+
end
|
|
84
|
+
Rails.autoloaders.main.on_unload { |_cpath, value, _abspath| detach(value) if installed? }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Rails' deprecated constant proxies print a warning on almost any method call, is_a? included.
|
|
88
|
+
def deprecated_constant?(mod)
|
|
89
|
+
defined?(ActiveSupport::Deprecation::DeprecatedConstantProxy) &&
|
|
90
|
+
Kernel.instance_method(:is_a?).bind_call(mod, ActiveSupport::Deprecation::DeprecatedConstantProxy)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def attach(mod)
|
|
94
|
+
return if @traces.nil? || @traces.key?(mod)
|
|
95
|
+
|
|
96
|
+
@traces[mod] = watched_methods(mod).map do |method|
|
|
97
|
+
TracePoint.new(:call, :return) { |tp| tp.event == :call ? handle_call(tp) : handle_return(tp) }
|
|
98
|
+
.tap { |trace| trace.enable(target: method) }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def detach(mod)
|
|
103
|
+
@traces&.delete(mod)&.each(&:disable)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Attr methods have no Ruby body to target, and aliases share one, so each body is traced once.
|
|
107
|
+
def watched_methods(mod)
|
|
108
|
+
[mod, mod.singleton_class].flat_map { |owner| own_methods(owner) }.uniq.select do |method|
|
|
109
|
+
RubyVM::InstructionSequence.of(method) && EventTimeline.configuration&.watched?(method.source_location&.first)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def own_methods(owner)
|
|
114
|
+
names = owner.instance_methods(false) + owner.private_instance_methods(false) +
|
|
115
|
+
owner.protected_instance_methods(false)
|
|
116
|
+
names.map { |name| owner.instance_method(name) }
|
|
117
|
+
end
|
|
118
|
+
|
|
75
119
|
def clean_backtrace(backtrace)
|
|
76
120
|
# Filter out gem internals, keep app code
|
|
77
121
|
backtrace.reject { |line| line.include?('/gems/') || line.include?('/ruby/') }
|
|
@@ -89,44 +133,43 @@ module EventTimeline
|
|
|
89
133
|
end
|
|
90
134
|
|
|
91
135
|
def handle_call(tp)
|
|
92
|
-
|
|
136
|
+
correlation_id = CurrentCorrelation.id
|
|
137
|
+
return unless correlation_id
|
|
93
138
|
|
|
94
|
-
|
|
95
|
-
|
|
139
|
+
event_id = next_event_id
|
|
140
|
+
signature = method_signature(tp)
|
|
96
141
|
|
|
97
|
-
push_to_stack(correlation_id, event_id,
|
|
98
|
-
buffer_call_event(correlation_id, event_id, tp)
|
|
142
|
+
push_to_stack(correlation_id, event_id, signature)
|
|
143
|
+
buffer_call_event(correlation_id, event_id, signature, tp)
|
|
99
144
|
rescue StandardError => e
|
|
100
145
|
Rails.logger.error "EventTimeline call tracking failed: #{e.message}" if defined?(Rails.logger)
|
|
101
146
|
end
|
|
102
147
|
|
|
103
148
|
def handle_return(tp)
|
|
104
|
-
# Skip watched? check - if we didn't track the call, pop_from_stack returns nil
|
|
105
149
|
correlation_id = CurrentCorrelation.id
|
|
106
150
|
return unless correlation_id
|
|
107
151
|
|
|
108
|
-
|
|
152
|
+
signature = method_signature(tp)
|
|
153
|
+
method_info = pop_from_stack(correlation_id, signature)
|
|
109
154
|
return unless method_info
|
|
110
155
|
|
|
111
|
-
buffer_return_event(correlation_id, method_info, tp)
|
|
156
|
+
buffer_return_event(correlation_id, method_info, signature, tp)
|
|
112
157
|
rescue StandardError => e
|
|
113
158
|
Rails.logger.error "EventTimeline return tracking failed: #{e.message}" if defined?(Rails.logger)
|
|
114
159
|
end
|
|
115
160
|
|
|
116
|
-
def push_to_stack(correlation_id, event_id,
|
|
161
|
+
def push_to_stack(correlation_id, event_id, signature)
|
|
117
162
|
stack = thread_method_stack[correlation_id] ||= []
|
|
118
163
|
stack.push(
|
|
119
164
|
event_id: event_id,
|
|
120
|
-
method:
|
|
165
|
+
method: signature
|
|
121
166
|
)
|
|
122
167
|
end
|
|
123
168
|
|
|
124
|
-
def pop_from_stack(correlation_id,
|
|
169
|
+
def pop_from_stack(correlation_id, signature)
|
|
125
170
|
stack = thread_method_stack[correlation_id]
|
|
126
171
|
return unless stack
|
|
127
172
|
|
|
128
|
-
signature = method_signature(tp)
|
|
129
|
-
|
|
130
173
|
# Find the LAST matching entry (proper LIFO for recursion)
|
|
131
174
|
index = stack.rindex { |m| m[:method] == signature }
|
|
132
175
|
return unless index
|
|
@@ -138,9 +181,9 @@ module EventTimeline
|
|
|
138
181
|
"#{tp.defined_class}##{tp.method_id}"
|
|
139
182
|
end
|
|
140
183
|
|
|
141
|
-
def buffer_call_event(correlation_id, event_id, tp)
|
|
184
|
+
def buffer_call_event(correlation_id, event_id, signature, tp)
|
|
142
185
|
buffer_event(
|
|
143
|
-
name:
|
|
186
|
+
name: signature,
|
|
144
187
|
severity: 'info',
|
|
145
188
|
category: 'method_call',
|
|
146
189
|
payload: {
|
|
@@ -152,13 +195,13 @@ module EventTimeline
|
|
|
152
195
|
params: capture_params(tp)
|
|
153
196
|
},
|
|
154
197
|
correlation_id: correlation_id,
|
|
155
|
-
occurred_at: Time.
|
|
198
|
+
occurred_at: Time.now
|
|
156
199
|
)
|
|
157
200
|
end
|
|
158
201
|
|
|
159
|
-
def buffer_return_event(correlation_id, method_info, tp)
|
|
202
|
+
def buffer_return_event(correlation_id, method_info, signature, tp)
|
|
160
203
|
buffer_event(
|
|
161
|
-
name: "#{
|
|
204
|
+
name: "#{signature}_return",
|
|
162
205
|
severity: 'info',
|
|
163
206
|
category: 'method_return',
|
|
164
207
|
payload: {
|
|
@@ -168,7 +211,7 @@ module EventTimeline
|
|
|
168
211
|
method: tp.method_id.to_s
|
|
169
212
|
},
|
|
170
213
|
correlation_id: correlation_id,
|
|
171
|
-
occurred_at: Time.
|
|
214
|
+
occurred_at: Time.now
|
|
172
215
|
)
|
|
173
216
|
end
|
|
174
217
|
|
|
@@ -180,15 +223,26 @@ module EventTimeline
|
|
|
180
223
|
Thread.current[THREAD_KEY_EVENT_BUFFER] ||= []
|
|
181
224
|
end
|
|
182
225
|
|
|
226
|
+
def next_event_id
|
|
227
|
+
Thread.current[THREAD_KEY_EVENT_SEQUENCE] = (Thread.current[THREAD_KEY_EVENT_SEQUENCE] || 0) + 1
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Past the per-request cap, keep the earliest events and mark the cut once.
|
|
183
231
|
def buffer_event(event)
|
|
184
|
-
|
|
232
|
+
buffer = thread_event_buffer
|
|
233
|
+
limit = EventTimeline.configuration&.max_events_per_correlation
|
|
234
|
+
if limit.nil? || buffer.size < limit
|
|
235
|
+
buffer << event
|
|
236
|
+
elsif buffer.size == limit
|
|
237
|
+
buffer << { name: 'EVENTS TRUNCATED', severity: 'warn', category: 'truncated', payload: { limit: limit },
|
|
238
|
+
correlation_id: event[:correlation_id], occurred_at: event[:occurred_at] }
|
|
239
|
+
end
|
|
185
240
|
end
|
|
186
241
|
|
|
187
242
|
def capture_params(tp)
|
|
188
|
-
method = tp.self.method(tp.method_id)
|
|
189
243
|
params = {}
|
|
190
244
|
|
|
191
|
-
|
|
245
|
+
tp.parameters.each do |_type, name|
|
|
192
246
|
next unless name
|
|
193
247
|
|
|
194
248
|
if tp.binding.local_variable_defined?(name)
|
|
@@ -201,16 +255,6 @@ module EventTimeline
|
|
|
201
255
|
rescue StandardError => e
|
|
202
256
|
{ error: "Failed to capture params: #{e.message}" }
|
|
203
257
|
end
|
|
204
|
-
|
|
205
|
-
def determine_correlation_id
|
|
206
|
-
if Thread.current[:request_id]
|
|
207
|
-
Thread.current[:request_id]
|
|
208
|
-
elsif Thread.current[:active_job_id]
|
|
209
|
-
Thread.current[:active_job_id]
|
|
210
|
-
else
|
|
211
|
-
SecureRandom.uuid.tap { |id| CurrentCorrelation.id = id }
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
258
|
end
|
|
215
259
|
end
|
|
216
260
|
end
|
|
@@ -24,41 +24,8 @@ module EventTimeline
|
|
|
24
24
|
perform_cleanup(total_count, max_total)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def enforce_correlation_limit(correlation_id, buffer_size = 0, force: false)
|
|
28
|
-
return unless EventTimeline.configuration
|
|
29
|
-
|
|
30
|
-
max_per_correlation = EventTimeline.configuration.max_events_per_correlation
|
|
31
|
-
|
|
32
|
-
unless force
|
|
33
|
-
# Track in-memory counts to avoid DB query on every flush
|
|
34
|
-
@correlation_counts ||= {}
|
|
35
|
-
@correlation_counts[correlation_id] ||= 0
|
|
36
|
-
@correlation_counts[correlation_id] += buffer_size
|
|
37
|
-
|
|
38
|
-
# Only hit DB when we think we might be near the limit
|
|
39
|
-
return unless @correlation_counts[correlation_id] >= (max_per_correlation * 0.9)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
current_count = Session.where(correlation_id: correlation_id).count
|
|
43
|
-
@correlation_counts[correlation_id] = current_count if @correlation_counts # Sync with reality
|
|
44
|
-
|
|
45
|
-
return unless current_count >= max_per_correlation
|
|
46
|
-
|
|
47
|
-
# Remove oldest events for this correlation, keeping last 80%
|
|
48
|
-
keep_count = (max_per_correlation * 0.8).to_i
|
|
49
|
-
oldest_events = Session.where(correlation_id: correlation_id)
|
|
50
|
-
.order(:occurred_at)
|
|
51
|
-
.limit(current_count - keep_count)
|
|
52
|
-
|
|
53
|
-
Session.where(id: oldest_events.pluck(:id)).delete_all
|
|
54
|
-
@correlation_counts[correlation_id] = keep_count if @correlation_counts
|
|
55
|
-
|
|
56
|
-
Rails.logger.info "EventTimeline: Rotated #{current_count - keep_count} events for correlation #{correlation_id}" if defined?(Rails.logger)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
27
|
def reset_counters!
|
|
60
28
|
@flush_counter = 0
|
|
61
|
-
@correlation_counts = {}
|
|
62
29
|
end
|
|
63
30
|
|
|
64
31
|
private
|
|
@@ -46,8 +46,12 @@ module EventTimeline
|
|
|
46
46
|
|
|
47
47
|
def inspect_collection(value)
|
|
48
48
|
max_length = EventTimeline.configuration&.max_inspect_length || 200
|
|
49
|
+
summary = "#{value.class}[#{value.size} items]"
|
|
50
|
+
# More items than characters allowed can never inspect short enough, so skip the inspect.
|
|
51
|
+
return summary if value.size > max_length
|
|
52
|
+
|
|
49
53
|
inspected = value.inspect
|
|
50
|
-
inspected.length > max_length ?
|
|
54
|
+
inspected.length > max_length ? summary : inspected
|
|
51
55
|
end
|
|
52
56
|
|
|
53
57
|
def inspect_activemodel(model)
|
|
@@ -57,6 +61,11 @@ module EventTimeline
|
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
def inspect_generic(value)
|
|
64
|
+
# Relation#inspect runs a query.
|
|
65
|
+
if defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)
|
|
66
|
+
return "#{value.klass.name}::Relation"
|
|
67
|
+
end
|
|
68
|
+
|
|
60
69
|
max_length = EventTimeline.configuration&.max_inspect_length || 200
|
|
61
70
|
result = value.inspect
|
|
62
71
|
result.length > max_length ? "#{value.class.name}[#{result.length} chars]" : result
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: event_timeline
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- svn-arv
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: rails
|
|
@@ -89,7 +88,6 @@ homepage: https://github.com/svn-arv/event_timeline
|
|
|
89
88
|
licenses:
|
|
90
89
|
- MIT
|
|
91
90
|
metadata: {}
|
|
92
|
-
post_install_message:
|
|
93
91
|
rdoc_options: []
|
|
94
92
|
require_paths:
|
|
95
93
|
- lib
|
|
@@ -104,8 +102,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
104
102
|
- !ruby/object:Gem::Version
|
|
105
103
|
version: '0'
|
|
106
104
|
requirements: []
|
|
107
|
-
rubygems_version: 3.
|
|
108
|
-
signing_key:
|
|
105
|
+
rubygems_version: 3.7.2
|
|
109
106
|
specification_version: 4
|
|
110
107
|
summary: Debug Rails apps by replaying method calls from any request
|
|
111
108
|
test_files: []
|