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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6bb353fe95dc4e1add0e9b01de0f938d1e9feb7dcffddf4501d9c4fc6d74a3d
4
- data.tar.gz: af1804a78347c235856b8a950bd843d412a5fe7724573a139653a5aef689460b
3
+ metadata.gz: 9dec56c4ce296fc94cb66f0065008565e072323dcd5b0dc4dd6f1bb719bf55f5
4
+ data.tar.gz: df8f292526aab228bef0084b8e455c6d002dd3b0a971ac328ca112e077f1e04b
5
5
  SHA512:
6
- metadata.gz: 3f58e34396e514e0f4affb1f71198b14711081147618d631c82b33b2ab49b8b8e15e5efe9b5a60bcc63e5f012aad80cb6b59da7263af2ca4969f2dd924d2bebf
7
- data.tar.gz: a21b2da8e8e4a60351238484b5927b02634c37f3f49b79ea053b6b64107528931cf94cf22c825b7c781640c5b31ac6a362bc528049624099c49032701e5197b8
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,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
- @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
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
- buffer_event(
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
- return unless EventTimeline.configuration&.watched?(tp.path)
136
+ correlation_id = CurrentCorrelation.id
137
+ return unless correlation_id
92
138
 
93
- correlation_id = CurrentCorrelation.id || determine_correlation_id
94
- event_id = SecureRandom.uuid
139
+ event_id = next_event_id
140
+ signature = method_signature(tp)
95
141
 
96
- push_to_stack(correlation_id, event_id, tp)
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
- return unless EventTimeline.configuration&.watched?(tp.path)
149
+ correlation_id = CurrentCorrelation.id
150
+ return unless correlation_id
104
151
 
105
- correlation_id = CurrentCorrelation.id || determine_correlation_id
106
- method_info = pop_from_stack(correlation_id, tp)
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, tp)
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: method_signature(tp)
165
+ method: signature
119
166
  )
120
167
  end
121
168
 
122
- def pop_from_stack(correlation_id, tp)
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: method_signature(tp),
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.current
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: "#{method_signature(tp)}_return",
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.current
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
- 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
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
- method.parameters.each do |_type, name|
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
- @watched_paths.any? do |pattern|
32
- File.fnmatch?(pattern, file_path, File::FNM_PATHNAME)
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 enforce_correlation_limit(correlation_id)
19
- return unless EventTimeline.configuration
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 ? "#{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.0'
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.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: 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: []