event_timeline 0.2.0 → 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 +96 -50
- data/lib/event_timeline/configuration.rb +10 -2
- data/lib/event_timeline/rotation_service.rb +12 -18
- 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,53 +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
35
|
Session.insert_all(buffer)
|
|
37
|
-
|
|
38
|
-
RotationService.enforce_correlation_limit(correlation_id)
|
|
39
36
|
RotationService.cleanup_if_needed
|
|
40
37
|
rescue StandardError => e
|
|
41
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
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def cleanup_thread_state(correlation_id)
|
|
45
45
|
thread_method_stack.delete(correlation_id)
|
|
46
46
|
Thread.current[THREAD_KEY_EVENT_BUFFER] = []
|
|
47
|
+
Thread.current[THREAD_KEY_EVENT_SEQUENCE] = nil
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def record_exception(exception, correlation_id)
|
|
50
51
|
backtrace = clean_backtrace(exception.backtrace || [])
|
|
51
52
|
source_location = extract_source_location(backtrace)
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
# Bypasses the cap: the exception is the event a capped request most needs.
|
|
55
|
+
thread_event_buffer << {
|
|
54
56
|
name: "EXCEPTION: #{exception.class.name}",
|
|
55
57
|
severity: 'error',
|
|
56
58
|
category: 'exception',
|
|
@@ -64,13 +66,56 @@ module EventTimeline
|
|
|
64
66
|
},
|
|
65
67
|
correlation_id: correlation_id,
|
|
66
68
|
occurred_at: Time.current
|
|
67
|
-
|
|
69
|
+
}
|
|
68
70
|
rescue StandardError => e
|
|
69
71
|
Rails.logger.error "EventTimeline exception recording failed: #{e.message}" if defined?(Rails.logger)
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
private
|
|
73
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
|
+
|
|
74
119
|
def clean_backtrace(backtrace)
|
|
75
120
|
# Filter out gem internals, keep app code
|
|
76
121
|
backtrace.reject { |line| line.include?('/gems/') || line.include?('/ruby/') }
|
|
@@ -88,43 +133,43 @@ module EventTimeline
|
|
|
88
133
|
end
|
|
89
134
|
|
|
90
135
|
def handle_call(tp)
|
|
91
|
-
|
|
136
|
+
correlation_id = CurrentCorrelation.id
|
|
137
|
+
return unless correlation_id
|
|
92
138
|
|
|
93
|
-
|
|
94
|
-
|
|
139
|
+
event_id = next_event_id
|
|
140
|
+
signature = method_signature(tp)
|
|
95
141
|
|
|
96
|
-
push_to_stack(correlation_id, event_id,
|
|
97
|
-
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)
|
|
98
144
|
rescue StandardError => e
|
|
99
145
|
Rails.logger.error "EventTimeline call tracking failed: #{e.message}" if defined?(Rails.logger)
|
|
100
146
|
end
|
|
101
147
|
|
|
102
148
|
def handle_return(tp)
|
|
103
|
-
|
|
149
|
+
correlation_id = CurrentCorrelation.id
|
|
150
|
+
return unless correlation_id
|
|
104
151
|
|
|
105
|
-
|
|
106
|
-
method_info = pop_from_stack(correlation_id,
|
|
152
|
+
signature = method_signature(tp)
|
|
153
|
+
method_info = pop_from_stack(correlation_id, signature)
|
|
107
154
|
return unless method_info
|
|
108
155
|
|
|
109
|
-
buffer_return_event(correlation_id, method_info, tp)
|
|
156
|
+
buffer_return_event(correlation_id, method_info, signature, tp)
|
|
110
157
|
rescue StandardError => e
|
|
111
158
|
Rails.logger.error "EventTimeline return tracking failed: #{e.message}" if defined?(Rails.logger)
|
|
112
159
|
end
|
|
113
160
|
|
|
114
|
-
def push_to_stack(correlation_id, event_id,
|
|
161
|
+
def push_to_stack(correlation_id, event_id, signature)
|
|
115
162
|
stack = thread_method_stack[correlation_id] ||= []
|
|
116
163
|
stack.push(
|
|
117
164
|
event_id: event_id,
|
|
118
|
-
method:
|
|
165
|
+
method: signature
|
|
119
166
|
)
|
|
120
167
|
end
|
|
121
168
|
|
|
122
|
-
def pop_from_stack(correlation_id,
|
|
169
|
+
def pop_from_stack(correlation_id, signature)
|
|
123
170
|
stack = thread_method_stack[correlation_id]
|
|
124
171
|
return unless stack
|
|
125
172
|
|
|
126
|
-
signature = method_signature(tp)
|
|
127
|
-
|
|
128
173
|
# Find the LAST matching entry (proper LIFO for recursion)
|
|
129
174
|
index = stack.rindex { |m| m[:method] == signature }
|
|
130
175
|
return unless index
|
|
@@ -136,9 +181,9 @@ module EventTimeline
|
|
|
136
181
|
"#{tp.defined_class}##{tp.method_id}"
|
|
137
182
|
end
|
|
138
183
|
|
|
139
|
-
def buffer_call_event(correlation_id, event_id, tp)
|
|
184
|
+
def buffer_call_event(correlation_id, event_id, signature, tp)
|
|
140
185
|
buffer_event(
|
|
141
|
-
name:
|
|
186
|
+
name: signature,
|
|
142
187
|
severity: 'info',
|
|
143
188
|
category: 'method_call',
|
|
144
189
|
payload: {
|
|
@@ -150,13 +195,13 @@ module EventTimeline
|
|
|
150
195
|
params: capture_params(tp)
|
|
151
196
|
},
|
|
152
197
|
correlation_id: correlation_id,
|
|
153
|
-
occurred_at: Time.
|
|
198
|
+
occurred_at: Time.now
|
|
154
199
|
)
|
|
155
200
|
end
|
|
156
201
|
|
|
157
|
-
def buffer_return_event(correlation_id, method_info, tp)
|
|
202
|
+
def buffer_return_event(correlation_id, method_info, signature, tp)
|
|
158
203
|
buffer_event(
|
|
159
|
-
name: "#{
|
|
204
|
+
name: "#{signature}_return",
|
|
160
205
|
severity: 'info',
|
|
161
206
|
category: 'method_return',
|
|
162
207
|
payload: {
|
|
@@ -166,7 +211,7 @@ module EventTimeline
|
|
|
166
211
|
method: tp.method_id.to_s
|
|
167
212
|
},
|
|
168
213
|
correlation_id: correlation_id,
|
|
169
|
-
occurred_at: Time.
|
|
214
|
+
occurred_at: Time.now
|
|
170
215
|
)
|
|
171
216
|
end
|
|
172
217
|
|
|
@@ -178,15 +223,26 @@ module EventTimeline
|
|
|
178
223
|
Thread.current[THREAD_KEY_EVENT_BUFFER] ||= []
|
|
179
224
|
end
|
|
180
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.
|
|
181
231
|
def buffer_event(event)
|
|
182
|
-
|
|
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
|
|
183
240
|
end
|
|
184
241
|
|
|
185
242
|
def capture_params(tp)
|
|
186
|
-
method = tp.self.method(tp.method_id)
|
|
187
243
|
params = {}
|
|
188
244
|
|
|
189
|
-
|
|
245
|
+
tp.parameters.each do |_type, name|
|
|
190
246
|
next unless name
|
|
191
247
|
|
|
192
248
|
if tp.binding.local_variable_defined?(name)
|
|
@@ -199,16 +255,6 @@ module EventTimeline
|
|
|
199
255
|
rescue StandardError => e
|
|
200
256
|
{ error: "Failed to capture params: #{e.message}" }
|
|
201
257
|
end
|
|
202
|
-
|
|
203
|
-
def determine_correlation_id
|
|
204
|
-
if Thread.current[:request_id]
|
|
205
|
-
Thread.current[:request_id]
|
|
206
|
-
elsif Thread.current[:active_job_id]
|
|
207
|
-
Thread.current[:active_job_id]
|
|
208
|
-
else
|
|
209
|
-
SecureRandom.uuid.tap { |id| CurrentCorrelation.id = id }
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
258
|
end
|
|
213
259
|
end
|
|
214
260
|
end
|
|
@@ -8,6 +8,7 @@ module EventTimeline
|
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@watched_paths = []
|
|
11
|
+
@watched_cache = {} # Cache for watched? lookups
|
|
11
12
|
@filtered_attributes = default_filtered_attributes
|
|
12
13
|
@max_events_per_correlation = 500 # Max events per correlation_id
|
|
13
14
|
@max_total_events = 10_000 # Max total events before cleanup
|
|
@@ -23,16 +24,23 @@ module EventTimeline
|
|
|
23
24
|
|
|
24
25
|
def watch(path)
|
|
25
26
|
@watched_paths << normalize_path(path)
|
|
27
|
+
@watched_cache.clear # Invalidate cache when paths change
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def watched?(file_path)
|
|
29
31
|
return false if @watched_paths.empty?
|
|
30
32
|
|
|
31
|
-
@
|
|
32
|
-
|
|
33
|
+
@watched_cache.fetch(file_path) do
|
|
34
|
+
@watched_cache[file_path] = @watched_paths.any? do |pattern|
|
|
35
|
+
File.fnmatch?(pattern, file_path, File::FNM_PATHNAME)
|
|
36
|
+
end
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
39
|
|
|
40
|
+
def clear_watched_cache!
|
|
41
|
+
@watched_cache.clear
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
def filter_pii(&block)
|
|
37
45
|
@pii_filter_proc = block if block_given?
|
|
38
46
|
end
|
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module EventTimeline
|
|
4
4
|
class RotationService
|
|
5
|
+
CLEANUP_CHECK_INTERVAL = 100 # Only check every N flushes
|
|
6
|
+
|
|
5
7
|
class << self
|
|
6
|
-
def cleanup_if_needed
|
|
8
|
+
def cleanup_if_needed(force: false)
|
|
7
9
|
return unless EventTimeline.configuration
|
|
8
10
|
|
|
11
|
+
unless force
|
|
12
|
+
# Probabilistic check - only run expensive Session.count every N requests
|
|
13
|
+
@flush_counter ||= 0
|
|
14
|
+
@flush_counter += 1
|
|
15
|
+
return unless (@flush_counter % CLEANUP_CHECK_INTERVAL).zero?
|
|
16
|
+
end
|
|
17
|
+
|
|
9
18
|
total_count = Session.count
|
|
10
19
|
max_total = EventTimeline.configuration.max_total_events
|
|
11
20
|
threshold = (max_total * EventTimeline.configuration.cleanup_threshold).to_i
|
|
@@ -15,23 +24,8 @@ module EventTimeline
|
|
|
15
24
|
perform_cleanup(total_count, max_total)
|
|
16
25
|
end
|
|
17
26
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
max_per_correlation = EventTimeline.configuration.max_events_per_correlation
|
|
22
|
-
current_count = Session.where(correlation_id: correlation_id).count
|
|
23
|
-
|
|
24
|
-
return unless current_count >= max_per_correlation
|
|
25
|
-
|
|
26
|
-
# Remove oldest events for this correlation, keeping last 80%
|
|
27
|
-
keep_count = (max_per_correlation * 0.8).to_i
|
|
28
|
-
oldest_events = Session.where(correlation_id: correlation_id)
|
|
29
|
-
.order(:occurred_at)
|
|
30
|
-
.limit(current_count - keep_count)
|
|
31
|
-
|
|
32
|
-
Session.where(id: oldest_events.pluck(:id)).delete_all
|
|
33
|
-
|
|
34
|
-
Rails.logger.info "EventTimeline: Rotated #{current_count - keep_count} events for correlation #{correlation_id}" if defined?(Rails.logger)
|
|
27
|
+
def reset_counters!
|
|
28
|
+
@flush_counter = 0
|
|
35
29
|
end
|
|
36
30
|
|
|
37
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: []
|