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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f9b39a082143ef828a48c1ba89377103062e5fd2d356658d257964727402c22
4
- data.tar.gz: f332374e2650e04ae7cfe4dee1ae27d98b11d268486284afbd02ddba8def7576
3
+ metadata.gz: 9dec56c4ce296fc94cb66f0065008565e072323dcd5b0dc4dd6f1bb719bf55f5
4
+ data.tar.gz: df8f292526aab228bef0084b8e455c6d002dd3b0a971ac328ca112e077f1e04b
5
5
  SHA512:
6
- metadata.gz: c7ff33b22475ea362d891023c7fc942e9ee85878d13fba5cf113426e3aea0ba1b48e10ad9a6f0ea101b5dd740900beaacd65b021a2a0903fc70f8d51ba2bc9b1
7
- data.tar.gz: 44ccc4f17fdb6e65f247a5209cec9cea0e6f458593ad113522b1da7056f9ca846db487a1f0d6cbbbcd5faca12c6be9be5ca1c917d17ae52674f23a58d43b25e3
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
- - TracePoint has some overhead - probably don't enable in high-traffic production without sampling
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
- @call_trace = TracePoint.new(:call) { |tp| handle_call(tp) }
15
- @return_trace = TracePoint.new(:return) { |tp| handle_return(tp) }
16
-
17
- @call_trace.enable
18
- @return_trace.enable
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
- @call_trace&.disable
23
- @return_trace&.disable
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
- @call_trace&.enabled? || @return_trace&.enabled?
28
+ !@traces.nil?
30
29
  end
31
30
 
32
- def flush_events(correlation_id)
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
- buffer_event(
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
- return unless EventTimeline.configuration&.watched?(tp.path)
136
+ correlation_id = CurrentCorrelation.id
137
+ return unless correlation_id
93
138
 
94
- correlation_id = CurrentCorrelation.id || determine_correlation_id
95
- event_id = SecureRandom.uuid
139
+ event_id = next_event_id
140
+ signature = method_signature(tp)
96
141
 
97
- push_to_stack(correlation_id, event_id, tp)
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
- method_info = pop_from_stack(correlation_id, tp)
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, tp)
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: method_signature(tp)
165
+ method: signature
121
166
  )
122
167
  end
123
168
 
124
- def pop_from_stack(correlation_id, tp)
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: method_signature(tp),
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.current
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: "#{method_signature(tp)}_return",
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.current
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
- thread_event_buffer << event
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
- method.parameters.each do |_type, name|
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 ? "#{value.class}[#{value.size} items]" : inspected
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EventTimeline
4
- VERSION = '0.2.1'
4
+ VERSION = '0.3.0'
5
5
  end
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.2.1
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: 2026-01-10 00:00:00.000000000 Z
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.4.10
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: []