event_timeline 0.1.0 → 0.2.1

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: acfe8f6da4ca9044255ba5db0c60ea66e6aaedfebd3b9c47a36eb2ace19631f6
4
- data.tar.gz: 9659ba87a4dd38ac503ddb6ee5573fb6088789240472e4e9f4cbe2ff0e063983
3
+ metadata.gz: 7f9b39a082143ef828a48c1ba89377103062e5fd2d356658d257964727402c22
4
+ data.tar.gz: f332374e2650e04ae7cfe4dee1ae27d98b11d268486284afbd02ddba8def7576
5
5
  SHA512:
6
- metadata.gz: 2d4e4efbc26ca24019666469a1d64d2f183f7c2264a0aecef97d61913fe018299f7fac4d00fa6e37d15f7fb39b064cd90d344bc02ba60f2e33460e309e4a3f42
7
- data.tar.gz: fab0b6ee70307348d2513ade4988e94bbe1abaca374bcac8ea5b6b64a4f14519ac8bfe226841e047f3cdbc3e5c8a066b834e818abea6d5d1ca6b1662de97c1c2
6
+ metadata.gz: c7ff33b22475ea362d891023c7fc942e9ee85878d13fba5cf113426e3aea0ba1b48e10ad9a6f0ea101b5dd740900beaacd65b021a2a0903fc70f8d51ba2bc9b1
7
+ data.tar.gz: 44ccc4f17fdb6e65f247a5209cec9cea0e6f458593ad113522b1da7056f9ca846db487a1f0d6cbbbcd5faca12c6be9be5ca1c917d17ae52674f23a58d43b25e3
data/README.md CHANGED
@@ -1,128 +1,95 @@
1
1
  # EventTimeline
2
2
 
3
- Ever wished you could replay what happened during a request? EventTimeline tracks method calls in your Rails app so you can see exactly how your code executed.
3
+ A Rails engine that records method calls during requests so you can see what actually happened.
4
4
 
5
- ## What it does
5
+ ## Why?
6
6
 
7
- EventTimeline records method calls and returns as they happen, letting you:
7
+ You get a bug report: "Order failed for user X". You check the logs, see a 500 error, but the stack trace doesn't tell you what state the app was in or what data was being processed.
8
8
 
9
- - See the exact sequence of method calls during a request
10
- - Inspect parameters passed to each method
11
- - View return values
12
- - Track execution flow across multiple services
13
- - Debug production issues by replaying what actually happened
9
+ EventTimeline records the method calls, parameters, and return values during the request. You visit `/event_timeline/sessions/<request_id>` and see exactly what happened.
14
10
 
15
- ## Installation
16
-
17
- Add to your Gemfile:
11
+ ## Install
18
12
 
19
13
  ```ruby
20
14
  gem 'event_timeline'
21
15
  ```
22
16
 
23
- Then:
24
-
25
17
  ```bash
26
18
  bundle install
27
19
  rails generate event_timeline:install
28
20
  rails db:migrate
29
21
  ```
30
22
 
31
- ## Basic Usage
23
+ ## Setup
32
24
 
33
- ### 1. Configure what to track
25
+ Tell it what to track:
34
26
 
35
27
  ```ruby
36
28
  # config/initializers/event_timeline.rb
37
29
  EventTimeline.configure do |config|
38
- # Track specific paths
39
30
  config.watch 'app/services'
40
- config.watch 'app/models/order.rb'
41
- config.watch 'lib/payment_processor'
31
+ config.watch 'app/models'
42
32
  end
43
33
  ```
44
34
 
45
- ### 2. View the timeline
46
-
47
- Visit `/event_timeline/sessions/:request_id` to see what happened during any request.
48
-
49
- Example: Your logs show request ID `abc-123-def` failed? Go to `/event_timeline/sessions/abc-123-def` and see every method that was called.
35
+ ## Usage
50
36
 
51
- ## Real-world Example
52
-
53
- Let's say a payment fails in production. Here's what you'd see:
37
+ Make a request, grab the request ID from logs, visit:
54
38
 
55
39
  ```
56
- OrdersController#create
57
- Order#initialize
58
- Order#validate_items
59
- Order#calculate_total
60
- Order#save
61
- PaymentService#charge
62
- PaymentGateway#create_charge
63
- <- Returns: {error: "Insufficient funds"}
64
- PaymentService#handle_failure
65
- Order#mark_as_failed
66
- CustomerMailer#payment_failed
40
+ /event_timeline/sessions/abc-123-def
67
41
  ```
68
42
 
69
- Now you know exactly where things went wrong and what data was involved.
43
+ You'll see the call timeline with params and return values. If the request crashed, you'll see the exception with its source location and backtrace.
70
44
 
71
- ## Configuration Options
45
+ ## Configuration
72
46
 
73
47
  ```ruby
74
48
  EventTimeline.configure do |config|
75
- # Filter sensitive data
76
- config.add_filtered_attributes :credit_card, :ssn, :api_key
49
+ # What to track
50
+ config.watch 'app/services'
51
+ config.watch 'lib/payments'
77
52
 
78
- # Custom PII filtering
79
- config.filter_pii do |key, value|
80
- return '<REDACTED>' if key.to_s =~ /bank_account/
81
- end
53
+ # Filter sensitive params (these are filtered by default: password, token, secret, etc.)
54
+ config.add_filtered_attributes :credit_card, :ssn
82
55
 
83
- # Customize how events are described
84
- config.narrator do |event|
85
- case event.name
86
- when /Stripe/
87
- "[PAYMENT] #{event.name}"
88
- else
89
- event.name
90
- end
56
+ # Custom filtering
57
+ config.filter_pii do |key, value, context|
58
+ key.to_s.include?('account_number') ? true : nil
91
59
  end
92
60
 
93
- # Data retention (defaults shown)
94
- config.max_events_per_correlation = 500 # Per request
95
- config.max_total_events = 10_000 # Total stored
96
- config.max_event_age = 1.month # Auto-delete after
61
+ # Retention
62
+ config.max_events_per_correlation = 500
63
+ config.max_total_events = 10_000
64
+ config.max_event_age = 1.month
65
+
66
+ # Truncation
67
+ config.max_string_length = 100
68
+ config.max_inspect_length = 200
97
69
  end
98
70
  ```
99
71
 
100
- ## Performance
72
+ ## Runtime control
101
73
 
102
- EventTimeline uses TracePoint which has minimal overhead. Data is automatically rotated to prevent unbounded growth:
103
-
104
- - Old events are deleted after 1 month
105
- - Per-request events are capped at 500
106
- - Total events are capped at 10,000
107
-
108
- ## Pro Tips
109
-
110
- 1. **Production Debugging**: When users report issues, ask for their request ID from the logs. EventTimeline will show you exactly what happened.
74
+ ```ruby
75
+ EventTimeline::CallTracker.uninstall! # stop tracking
76
+ EventTimeline::CallTracker.install! # start tracking
77
+ EventTimeline::CallTracker.installed? # check status
78
+ ```
111
79
 
112
- 2. **Development**: Watch your code execute in real-time. Great for understanding unfamiliar codebases.
80
+ ## Custom correlation IDs
113
81
 
114
- 3. **Testing**: Verify your code follows the expected execution path.
82
+ For background jobs or anything outside a request:
115
83
 
116
- 4. **Correlation IDs**: EventTimeline automatically groups events by request ID, but you can set custom correlation IDs:
117
- ```ruby
118
- EventTimeline::CurrentCorrelation.id = "import-job-#{job.id}"
119
- ```
84
+ ```ruby
85
+ EventTimeline::CurrentCorrelation.id = "import-job-#{job.id}"
86
+ ```
120
87
 
121
88
  ## Limitations
122
89
 
123
- - Only tracks Ruby method calls (not database queries or external HTTP calls)
124
- - TracePoint doesn't work with some metaprogramming techniques
125
- - Large payloads are truncated to keep storage reasonable
90
+ - 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
92
+ - Large values get truncated
126
93
 
127
94
  ## License
128
95
 
data/Rakefile CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/setup'
4
-
5
4
  require 'bundler/gem_tasks'
5
+
6
+ # Load all rake tasks
7
+ Dir.glob('tasks/**/*.rake').each { |task| load task }
@@ -1,40 +1,82 @@
1
1
  <div class="container">
2
2
  <h1>Timeline for <span class="correlation-id"><%= @correlation_id %></span></h1>
3
3
 
4
+ <% has_exception = @events.any? { |e| e.category == 'exception' } %>
5
+ <% if @events.any? %>
6
+ <div class="status-badge <%= has_exception ? 'failed' : 'success' %>">
7
+ <%= has_exception ? 'FAILED' : 'SUCCESS' %>
8
+ </div>
9
+ <% end %>
10
+
4
11
  <div class="timeline">
5
12
  <% @events.each do |event| %>
6
13
  <% is_return = event.category == "method_return" %>
7
- <div class="event <%= event.severity %> <%= 'return' if is_return %>">
8
- <div class="event-header">
9
- <span class="event-name">
10
- <% if is_return %>
11
- &lt;- <%= event.name.gsub('_return', '') %>
12
- <% else %>
13
- <%= narrate_event(event) %>
14
- <% end %>
15
- </span>
14
+ <% is_exception = event.category == "exception" %>
15
+
16
+ <% if is_exception %>
17
+ <div class="event exception">
18
+ <div class="exception-header">
19
+ <%= event.payload['exception_class'] %>
20
+ </div>
21
+
22
+ <div class="exception-message">
23
+ <%= event.payload['message'] %>
24
+ </div>
25
+
26
+ <% if event.payload['source_file'] %>
27
+ <div class="exception-location">
28
+ Raised in <strong><%= event.payload['source_method'] %></strong>
29
+ at <%= event.payload['source_file'] %>:<%= event.payload['source_line'] %>
30
+ </div>
31
+ <% end %>
32
+
16
33
  <span class="event-time"><%= event.occurred_at.strftime("%H:%M:%S.%L") %></span>
34
+
35
+ <% if event.payload['backtrace'].present? %>
36
+ <details>
37
+ <summary>Show backtrace</summary>
38
+ <div class="exception-backtrace">
39
+ <% event.payload['backtrace'].each do |line| %>
40
+ <div class="exception-backtrace-line"><%= line %></div>
41
+ <% end %>
42
+ </div>
43
+ </details>
44
+ <% end %>
17
45
  </div>
18
46
 
19
- <% if event.payload.present? %>
20
- <% if is_return %>
21
- <div style="font-size: 14px; color: #059862;">
22
- Returns: <%= event.payload['return_value'] %>
23
- </div>
24
- <% elsif event.payload['params'].present? && !event.payload['params'].empty? %>
25
- <div style="font-size: 14px; color: #0066cc;">
26
- Params: <%= event.payload['params'].inspect %>
27
- </div>
47
+ <% else %>
48
+ <div class="event <%= event.severity %> <%= 'return' if is_return %>">
49
+ <div class="event-header">
50
+ <span class="event-name">
51
+ <% if is_return %>
52
+ &lt;- <%= event.name.gsub('_return', '') %>
53
+ <% else %>
54
+ <%= narrate_event(event) %>
55
+ <% end %>
56
+ </span>
57
+ <span class="event-time"><%= event.occurred_at.strftime("%H:%M:%S.%L") %></span>
58
+ </div>
59
+
60
+ <% if event.payload.present? %>
61
+ <% if is_return %>
62
+ <div style="font-size: 14px; color: #059862;">
63
+ Returns: <%= event.payload['return_value'] %>
64
+ </div>
65
+ <% elsif event.payload['params'].present? && !event.payload['params'].empty? %>
66
+ <div style="font-size: 14px; color: #0066cc;">
67
+ Params: <%= event.payload['params'].inspect %>
68
+ </div>
69
+ <% end %>
70
+
71
+ <details>
72
+ <summary>Show full payload</summary>
73
+ <div class="event-payload">
74
+ <%= JSON.pretty_generate(event.payload) %>
75
+ </div>
76
+ </details>
28
77
  <% end %>
29
-
30
- <details>
31
- <summary>Show full payload</summary>
32
- <div class="event-payload">
33
- <%= JSON.pretty_generate(event.payload) %>
34
- </div>
35
- </details>
36
- <% end %>
37
- </div>
78
+ </div>
79
+ <% end %>
38
80
  <% end %>
39
81
 
40
82
  <% if @events.empty? %>
@@ -61,6 +61,65 @@
61
61
  background: #dc3545;
62
62
  border-color: #dc3545;
63
63
  }
64
+ .event.exception {
65
+ background: #fff5f5;
66
+ border: 2px solid #dc3545;
67
+ }
68
+ .event.exception::before {
69
+ background: #dc3545;
70
+ border-color: #dc3545;
71
+ width: 14px;
72
+ height: 14px;
73
+ left: -27px;
74
+ top: 18px;
75
+ }
76
+ .exception-header {
77
+ color: #dc3545;
78
+ font-weight: bold;
79
+ font-size: 16px;
80
+ }
81
+ .exception-message {
82
+ background: #f8d7da;
83
+ color: #721c24;
84
+ padding: 10px 15px;
85
+ border-radius: 4px;
86
+ margin: 10px 0;
87
+ font-family: monospace;
88
+ }
89
+ .exception-location {
90
+ font-size: 13px;
91
+ color: #666;
92
+ margin-bottom: 10px;
93
+ }
94
+ .exception-backtrace {
95
+ background: #282c34;
96
+ color: #abb2bf;
97
+ padding: 15px;
98
+ border-radius: 4px;
99
+ font-family: monospace;
100
+ font-size: 12px;
101
+ overflow-x: auto;
102
+ margin-top: 10px;
103
+ }
104
+ .exception-backtrace-line {
105
+ margin: 2px 0;
106
+ }
107
+ .status-badge {
108
+ display: inline-block;
109
+ padding: 4px 10px;
110
+ border-radius: 4px;
111
+ font-size: 12px;
112
+ font-weight: bold;
113
+ margin-bottom: 15px;
114
+ }
115
+ .status-badge.success {
116
+ background: #d4edda;
117
+ color: #155724;
118
+ }
119
+ .status-badge.failed {
120
+ background: #f8d7da;
121
+ color: #721c24;
122
+ }
64
123
  .event.warning::before {
65
124
  background: #ffc107;
66
125
  border-color: #ffc107;
@@ -2,201 +2,211 @@
2
2
 
3
3
  module EventTimeline
4
4
  class CallTracker
5
+ THREAD_KEY_METHOD_STACK = :event_timeline_method_stack
6
+ THREAD_KEY_EVENT_BUFFER = :event_timeline_event_buffer
7
+
5
8
  class << self
9
+ attr_reader :call_trace, :return_trace
10
+
6
11
  def install!
7
- @last_location = {}
8
- @method_stack = Hash.new { |h, k| h[k] = [] }
9
-
10
- call_trace = TracePoint.new(:call) do |tp|
11
- next unless EventTimeline.configuration&.watched?(tp.path)
12
-
13
- current_location = "#{tp.path}:#{tp.lineno}"
14
- correlation_id = CurrentCorrelation.id || determine_correlation_id
15
-
16
- if @last_location[correlation_id] != current_location
17
- @last_location[correlation_id] = current_location
18
-
19
- # Capture method arguments
20
- params = capture_params(tp)
21
-
22
- event_id = SecureRandom.uuid
23
- @method_stack[correlation_id].push({
24
- event_id: event_id,
25
- method: "#{tp.defined_class}##{tp.method_id}"
26
- })
27
-
28
- Session.create!(
29
- name: "#{tp.defined_class}##{tp.method_id}",
30
- severity: 'info',
31
- category: 'method_call',
32
- payload: {
33
- event_id: event_id,
34
- file: tp.path,
35
- line: tp.lineno,
36
- class: tp.defined_class.to_s,
37
- method: tp.method_id.to_s,
38
- params: params
39
- },
40
- correlation_id: correlation_id,
41
- occurred_at: Time.current
42
- )
43
-
44
- # Enforce rotation limits
45
- RotationService.enforce_correlation_limit(correlation_id)
46
- RotationService.cleanup_if_needed
47
- end
48
- rescue StandardError => e
49
- Rails.logger.error "EventTimeline call tracking failed: #{e.message}" if defined?(Rails.logger)
50
- end
12
+ return if installed?
51
13
 
52
- return_trace = TracePoint.new(:return) do |tp|
53
- next unless EventTimeline.configuration&.watched?(tp.path)
54
-
55
- correlation_id = CurrentCorrelation.id || determine_correlation_id
56
- method_info = @method_stack[correlation_id].find { |m| m[:method] == "#{tp.defined_class}##{tp.method_id}" }
57
-
58
- if method_info
59
- @method_stack[correlation_id].delete(method_info)
60
-
61
- Session.create!(
62
- name: "#{tp.defined_class}##{tp.method_id}_return",
63
- severity: 'info',
64
- category: 'method_return',
65
- payload: {
66
- event_id: method_info[:event_id],
67
- return_value: filter_sensitive_data(:return_value, tp.return_value, { context: :return_value }),
68
- class: tp.defined_class.to_s,
69
- method: tp.method_id.to_s
70
- },
71
- correlation_id: correlation_id,
72
- occurred_at: Time.current
73
- )
74
- end
75
- rescue StandardError => e
76
- Rails.logger.error "EventTimeline return tracking failed: #{e.message}" if defined?(Rails.logger)
77
- end
14
+ @call_trace = TracePoint.new(:call) { |tp| handle_call(tp) }
15
+ @return_trace = TracePoint.new(:return) { |tp| handle_return(tp) }
78
16
 
79
- call_trace.enable
80
- return_trace.enable
17
+ @call_trace.enable
18
+ @return_trace.enable
81
19
  end
82
20
 
83
- private
21
+ def uninstall!
22
+ @call_trace&.disable
23
+ @return_trace&.disable
24
+ @call_trace = nil
25
+ @return_trace = nil
26
+ end
84
27
 
85
- def capture_params(tp)
86
- method = tp.self.method(tp.method_id)
87
- params = {}
28
+ def installed?
29
+ @call_trace&.enabled? || @return_trace&.enabled?
30
+ end
88
31
 
89
- method.parameters.each_value do |name|
90
- if tp.binding.local_variable_defined?(name)
91
- value = tp.binding.local_variable_get(name)
92
- params[name] = filter_sensitive_data(name, value, { context: :parameter })
93
- end
94
- end
32
+ def flush_events(correlation_id)
33
+ buffer = thread_event_buffer
34
+ return if buffer.empty?
95
35
 
96
- params
36
+ buffer_size = buffer.size
37
+ Session.insert_all(buffer)
38
+
39
+ RotationService.enforce_correlation_limit(correlation_id, buffer_size)
40
+ RotationService.cleanup_if_needed
97
41
  rescue StandardError => e
98
- { error: "Failed to capture params: #{e.message}" }
42
+ Rails.logger.error "EventTimeline flush failed: #{e.message}" if defined?(Rails.logger)
99
43
  end
100
44
 
101
- def safe_inspect(value)
102
- case value
103
- when String
104
- value.length > 100 ? "#{value[0..100]}..." : value
105
- when Hash, Array
106
- value.inspect.length > 200 ? "#{value.class}[#{value.size} items]" : value.inspect
107
- when defined?(ActiveRecord::Base) && ActiveRecord::Base
108
- inspect_activerecord(value)
109
- when defined?(ActiveModel::Model) && ActiveModel::Model
110
- inspect_model(value)
111
- when Class
112
- value.name
113
- when Module
114
- value.name
115
- else
116
- simple_inspect(value)
117
- end
45
+ def cleanup_thread_state(correlation_id)
46
+ thread_method_stack.delete(correlation_id)
47
+ Thread.current[THREAD_KEY_EVENT_BUFFER] = []
48
+ end
49
+
50
+ def record_exception(exception, correlation_id)
51
+ backtrace = clean_backtrace(exception.backtrace || [])
52
+ source_location = extract_source_location(backtrace)
53
+
54
+ buffer_event(
55
+ name: "EXCEPTION: #{exception.class.name}",
56
+ severity: 'error',
57
+ category: 'exception',
58
+ payload: {
59
+ exception_class: exception.class.name,
60
+ message: exception.message,
61
+ backtrace: backtrace.first(10),
62
+ source_file: source_location[:file],
63
+ source_line: source_location[:line],
64
+ source_method: source_location[:method]
65
+ },
66
+ correlation_id: correlation_id,
67
+ occurred_at: Time.current
68
+ )
118
69
  rescue StandardError => e
119
- "<inspect failed: #{e.message}>"
70
+ Rails.logger.error "EventTimeline exception recording failed: #{e.message}" if defined?(Rails.logger)
71
+ end
72
+
73
+ private
74
+
75
+ def clean_backtrace(backtrace)
76
+ # Filter out gem internals, keep app code
77
+ backtrace.reject { |line| line.include?('/gems/') || line.include?('/ruby/') }
120
78
  end
121
79
 
122
- def inspect_activerecord(record)
123
- if record.persisted?
124
- id_attr = record.class.primary_key
125
- id_value = record.send(id_attr) if id_attr
126
- "#{record.class.name}(#{id_attr}: #{id_value})"
80
+ def extract_source_location(backtrace)
81
+ return { file: nil, line: nil, method: nil } if backtrace.empty?
82
+
83
+ # Parse first line: "/path/to/file.rb:123:in `method_name'"
84
+ if backtrace.first =~ /\A(.+):(\d+):in `(.+)'\z/
85
+ { file: ::Regexp.last_match(1), line: ::Regexp.last_match(2).to_i, method: ::Regexp.last_match(3) }
127
86
  else
128
- "#{record.class.name}(new_record)"
87
+ { file: backtrace.first, line: nil, method: nil }
129
88
  end
89
+ end
90
+
91
+ def handle_call(tp)
92
+ return unless EventTimeline.configuration&.watched?(tp.path)
93
+
94
+ correlation_id = CurrentCorrelation.id || determine_correlation_id
95
+ event_id = SecureRandom.uuid
96
+
97
+ push_to_stack(correlation_id, event_id, tp)
98
+ buffer_call_event(correlation_id, event_id, tp)
130
99
  rescue StandardError => e
131
- "#{record.class.name}(<inspection failed>)"
100
+ Rails.logger.error "EventTimeline call tracking failed: #{e.message}" if defined?(Rails.logger)
132
101
  end
133
102
 
134
- def inspect_model(model)
135
- "#{model.class.name}(#{model.class.attribute_names.size} attributes)"
103
+ def handle_return(tp)
104
+ # Skip watched? check - if we didn't track the call, pop_from_stack returns nil
105
+ correlation_id = CurrentCorrelation.id
106
+ return unless correlation_id
107
+
108
+ method_info = pop_from_stack(correlation_id, tp)
109
+ return unless method_info
110
+
111
+ buffer_return_event(correlation_id, method_info, tp)
136
112
  rescue StandardError => e
137
- "#{model.class.name}(<inspection failed>)"
113
+ Rails.logger.error "EventTimeline return tracking failed: #{e.message}" if defined?(Rails.logger)
138
114
  end
139
115
 
140
- def simple_inspect(value)
141
- result = value.inspect
142
- if result.length > 200
143
- "#{value.class.name}[#{result.length} chars]"
144
- else
145
- result
146
- end
116
+ def push_to_stack(correlation_id, event_id, tp)
117
+ stack = thread_method_stack[correlation_id] ||= []
118
+ stack.push(
119
+ event_id: event_id,
120
+ method: method_signature(tp)
121
+ )
147
122
  end
148
123
 
149
- def filter_sensitive_data(key, value, context = {})
150
- if EventTimeline.configuration&.should_filter?(key, value, context)
151
- case value
152
- when String
153
- '<FILTERED>'
154
- when Hash
155
- filter_hash(value)
156
- when Array
157
- value.map.with_index { |item, index| filter_sensitive_data("item_#{index}", item, context) }
158
- when defined?(ActiveRecord::Base) && ActiveRecord::Base
159
- filter_activerecord(value)
160
- else
161
- '<FILTERED>'
162
- end
163
- else
164
- safe_inspect(value)
165
- end
124
+ def pop_from_stack(correlation_id, tp)
125
+ stack = thread_method_stack[correlation_id]
126
+ return unless stack
127
+
128
+ signature = method_signature(tp)
129
+
130
+ # Find the LAST matching entry (proper LIFO for recursion)
131
+ index = stack.rindex { |m| m[:method] == signature }
132
+ return unless index
133
+
134
+ stack.delete_at(index)
166
135
  end
167
136
 
168
- def filter_hash(hash)
169
- filtered = {}
170
- hash.each do |key, value|
171
- filtered[key] = filter_sensitive_data(key, value, { context: :hash_value })
172
- end
173
- filtered
137
+ def method_signature(tp)
138
+ "#{tp.defined_class}##{tp.method_id}"
174
139
  end
175
140
 
176
- def filter_activerecord(record)
177
- if EventTimeline.configuration&.should_filter?(:activerecord, record, { context: :model })
178
- '<FILTERED>'
179
- else
180
- # Show model with filtered attributes
181
- filtered_attrs = {}
182
- record.attributes.each do |key, value|
183
- filtered_attrs[key] = if EventTimeline.configuration&.should_filter?(key, value, { context: :attribute })
184
- '<FILTERED>'
185
- else
186
- safe_inspect(value)
187
- end
141
+ def buffer_call_event(correlation_id, event_id, tp)
142
+ buffer_event(
143
+ name: method_signature(tp),
144
+ severity: 'info',
145
+ category: 'method_call',
146
+ payload: {
147
+ event_id: event_id,
148
+ file: tp.path,
149
+ line: tp.lineno,
150
+ class: tp.defined_class.to_s,
151
+ method: tp.method_id.to_s,
152
+ params: capture_params(tp)
153
+ },
154
+ correlation_id: correlation_id,
155
+ occurred_at: Time.current
156
+ )
157
+ end
158
+
159
+ def buffer_return_event(correlation_id, method_info, tp)
160
+ buffer_event(
161
+ name: "#{method_signature(tp)}_return",
162
+ severity: 'info',
163
+ category: 'method_return',
164
+ payload: {
165
+ event_id: method_info[:event_id],
166
+ return_value: ValueFilter.filter(:return_value, tp.return_value, { context: :return_value }),
167
+ class: tp.defined_class.to_s,
168
+ method: tp.method_id.to_s
169
+ },
170
+ correlation_id: correlation_id,
171
+ occurred_at: Time.current
172
+ )
173
+ end
174
+
175
+ def thread_method_stack
176
+ Thread.current[THREAD_KEY_METHOD_STACK] ||= {}
177
+ end
178
+
179
+ def thread_event_buffer
180
+ Thread.current[THREAD_KEY_EVENT_BUFFER] ||= []
181
+ end
182
+
183
+ def buffer_event(event)
184
+ thread_event_buffer << event
185
+ end
186
+
187
+ def capture_params(tp)
188
+ method = tp.self.method(tp.method_id)
189
+ params = {}
190
+
191
+ method.parameters.each do |_type, name|
192
+ next unless name
193
+
194
+ if tp.binding.local_variable_defined?(name)
195
+ value = tp.binding.local_variable_get(name)
196
+ params[name] = ValueFilter.filter(name, value, { context: :parameter })
188
197
  end
189
- "#{inspect_activerecord(record)} {#{filtered_attrs.map { |k, v| "#{k}: #{v}" }.join(', ')}}"
190
198
  end
199
+
200
+ params
191
201
  rescue StandardError => e
192
- "#{inspect_activerecord(record)} <filtering failed>"
202
+ { error: "Failed to capture params: #{e.message}" }
193
203
  end
194
204
 
195
205
  def determine_correlation_id
196
206
  if Thread.current[:request_id]
197
207
  Thread.current[:request_id]
198
- elsif defined?(ActiveJob::Base) && ActiveJob::Base.current_execution&.job_id
199
- ActiveJob::Base.current_execution.job_id
208
+ elsif Thread.current[:active_job_id]
209
+ Thread.current[:active_job_id]
200
210
  else
201
211
  SecureRandom.uuid.tap { |id| CurrentCorrelation.id = id }
202
212
  end
@@ -3,15 +3,19 @@
3
3
  module EventTimeline
4
4
  class Configuration
5
5
  attr_accessor :narrator_proc, :watched_paths, :pii_filter_proc, :filtered_attributes,
6
- :max_events_per_correlation, :max_total_events, :cleanup_threshold, :max_event_age
6
+ :max_events_per_correlation, :max_total_events, :cleanup_threshold, :max_event_age,
7
+ :max_string_length, :max_inspect_length
7
8
 
8
9
  def initialize
9
10
  @watched_paths = []
11
+ @watched_cache = {} # Cache for watched? lookups
10
12
  @filtered_attributes = default_filtered_attributes
11
- @max_events_per_correlation = 500 # Max events per correlation_id
12
- @max_total_events = 10_000 # Max total events before cleanup
13
- @cleanup_threshold = 0.8 # Start cleanup at 80% of max
14
- @max_event_age = 1.month # Delete events older than this
13
+ @max_events_per_correlation = 500 # Max events per correlation_id
14
+ @max_total_events = 10_000 # Max total events before cleanup
15
+ @cleanup_threshold = 0.8 # Start cleanup at 80% of max
16
+ @max_event_age = 1.month # Delete events older than this
17
+ @max_string_length = 100 # Truncate strings longer than this
18
+ @max_inspect_length = 200 # Truncate inspected values longer than this
15
19
  end
16
20
 
17
21
  def narrator(&block)
@@ -20,16 +24,23 @@ module EventTimeline
20
24
 
21
25
  def watch(path)
22
26
  @watched_paths << normalize_path(path)
27
+ @watched_cache.clear # Invalidate cache when paths change
23
28
  end
24
29
 
25
30
  def watched?(file_path)
26
31
  return false if @watched_paths.empty?
27
32
 
28
- @watched_paths.any? do |pattern|
29
- 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
30
37
  end
31
38
  end
32
39
 
40
+ def clear_watched_cache!
41
+ @watched_cache.clear
42
+ end
43
+
33
44
  def filter_pii(&block)
34
45
  @pii_filter_proc = block if block_given?
35
46
  end
@@ -81,7 +92,11 @@ module EventTimeline
81
92
  def normalize_path(path)
82
93
  path = path.to_s
83
94
  path = "#{Rails.root}/#{path}" unless path.start_with?('/')
84
- "#{path.gsub(%r{/$}, '')}/**/*.rb" unless path.include?('*')
95
+ if path.include?('*')
96
+ path
97
+ else
98
+ "#{path.gsub(%r{/$}, '')}/**/*.rb"
99
+ end
85
100
  end
86
101
  end
87
102
  end
@@ -4,14 +4,12 @@ module EventTimeline
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace EventTimeline
6
6
 
7
- initializer 'timeline.install_call_tracker' do
8
- ActiveSupport.on_load(:after_initialize) do
9
- EventTimeline::CallTracker.install!
10
- end
7
+ initializer 'event_timeline.setup_middleware' do |app|
8
+ app.middleware.use EventTimeline::Middleware
11
9
  end
12
10
 
13
- initializer 'timeline.setup_middleware' do |app|
14
- app.middleware.use EventTimeline::Middleware
11
+ config.after_initialize do
12
+ EventTimeline::CallTracker.install! if EventTimeline.configuration
15
13
  end
16
14
  end
17
15
  end
@@ -8,11 +8,21 @@ module EventTimeline
8
8
 
9
9
  def call(env)
10
10
  request = ActionDispatch::Request.new(env)
11
- CurrentCorrelation.id = request.request_id
12
- Thread.current[:request_id] = request.request_id
11
+ correlation_id = request.request_id
12
+ CurrentCorrelation.id = correlation_id
13
+ Thread.current[:request_id] = correlation_id
13
14
 
14
15
  @app.call(env)
16
+ rescue Exception => e # rubocop:disable Lint/RescueException
17
+ # Capture the exception before re-raising
18
+ CallTracker.record_exception(e, correlation_id) if correlation_id
19
+ raise
15
20
  ensure
21
+ # Flush buffered events to database
22
+ CallTracker.flush_events(correlation_id) if correlation_id
23
+
24
+ # Clean up thread-local state
25
+ CallTracker.cleanup_thread_state(correlation_id) if correlation_id
16
26
  CurrentCorrelation.reset
17
27
  Thread.current[:request_id] = nil
18
28
  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,11 +24,23 @@ module EventTimeline
15
24
  perform_cleanup(total_count, max_total)
16
25
  end
17
26
 
18
- def enforce_correlation_limit(correlation_id)
27
+ def enforce_correlation_limit(correlation_id, buffer_size = 0, force: false)
19
28
  return unless EventTimeline.configuration
20
29
 
21
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
+
22
42
  current_count = Session.where(correlation_id: correlation_id).count
43
+ @correlation_counts[correlation_id] = current_count if @correlation_counts # Sync with reality
23
44
 
24
45
  return unless current_count >= max_per_correlation
25
46
 
@@ -30,10 +51,16 @@ module EventTimeline
30
51
  .limit(current_count - keep_count)
31
52
 
32
53
  Session.where(id: oldest_events.pluck(:id)).delete_all
54
+ @correlation_counts[correlation_id] = keep_count if @correlation_counts
33
55
 
34
56
  Rails.logger.info "EventTimeline: Rotated #{current_count - keep_count} events for correlation #{correlation_id}" if defined?(Rails.logger)
35
57
  end
36
58
 
59
+ def reset_counters!
60
+ @flush_counter = 0
61
+ @correlation_counts = {}
62
+ end
63
+
37
64
  private
38
65
 
39
66
  def perform_cleanup(current_count, max_count)
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class ValueFilter
5
+ FILTERED = '<FILTERED>'
6
+
7
+ class << self
8
+ def filter(key, value, context = {})
9
+ if should_filter?(key, value, context)
10
+ filter_sensitive_value(value, context)
11
+ else
12
+ filter_nested(key, value, context)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def should_filter?(key, value, context)
19
+ EventTimeline.configuration&.should_filter?(key, value, context)
20
+ end
21
+
22
+ def filter_sensitive_value(value, context)
23
+ case value
24
+ when String
25
+ FILTERED
26
+ when Hash
27
+ filter_hash(value, context)
28
+ when Array
29
+ filter_array(value, context)
30
+ else
31
+ return filter_activerecord(value) if ValueInspector.activerecord_model?(value)
32
+
33
+ FILTERED
34
+ end
35
+ end
36
+
37
+ def filter_nested(key, value, context)
38
+ case value
39
+ when Hash
40
+ filter_hash(value, context)
41
+ when Array
42
+ filter_array(value, context)
43
+ else
44
+ return filter_activerecord(value) if ValueInspector.activerecord_model?(value)
45
+
46
+ ValueInspector.inspect(value)
47
+ end
48
+ end
49
+
50
+ def filter_hash(hash, context = {})
51
+ hash.each_with_object({}) do |(key, value), filtered|
52
+ filtered[key] = filter(key, value, context.merge(context: :hash_value))
53
+ end
54
+ end
55
+
56
+ def filter_array(array, context = {})
57
+ array.map.with_index do |item, index|
58
+ filter("item_#{index}", item, context)
59
+ end
60
+ end
61
+
62
+ def filter_activerecord(record)
63
+ if should_filter?(:activerecord, record, { context: :model })
64
+ FILTERED
65
+ else
66
+ filter_activerecord_attributes(record)
67
+ end
68
+ rescue StandardError
69
+ "#{ValueInspector.inspect_activerecord(record)} <filtering failed>"
70
+ end
71
+
72
+ def filter_activerecord_attributes(record)
73
+ filtered_attrs = record.attributes.each_with_object({}) do |(key, value), attrs|
74
+ attrs[key] = if should_filter?(key, value, { context: :attribute })
75
+ FILTERED
76
+ else
77
+ ValueInspector.inspect(value)
78
+ end
79
+ end
80
+
81
+ "#{ValueInspector.inspect_activerecord(record)} {#{filtered_attrs.map { |k, v| "#{k}: #{v}" }.join(', ')}}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class ValueInspector
5
+ class << self
6
+ def inspect(value)
7
+ return inspect_string(value) if value.is_a?(String)
8
+ return inspect_collection(value) if value.is_a?(Hash) || value.is_a?(Array)
9
+ return inspect_activerecord(value) if activerecord_model?(value)
10
+ return inspect_activemodel(value) if activemodel_model?(value)
11
+ return value.name if value.is_a?(Class) || value.is_a?(Module)
12
+
13
+ inspect_generic(value)
14
+ rescue StandardError
15
+ '<inspect failed>'
16
+ end
17
+
18
+ def inspect_activerecord(record)
19
+ if record.persisted?
20
+ id_attr = record.class.primary_key
21
+ id_value = record.send(id_attr) if id_attr
22
+ "#{record.class.name}(#{id_attr}: #{id_value})"
23
+ else
24
+ "#{record.class.name}(new_record)"
25
+ end
26
+ rescue StandardError
27
+ "#{record.class.name}(<inspection failed>)"
28
+ end
29
+
30
+ def activerecord_model?(value)
31
+ defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
32
+ end
33
+
34
+ def activemodel_model?(value)
35
+ return false unless defined?(ActiveModel::Model)
36
+
37
+ value.class.included_modules.include?(ActiveModel::Model)
38
+ end
39
+
40
+ private
41
+
42
+ def inspect_string(value)
43
+ max_length = EventTimeline.configuration&.max_string_length || 100
44
+ value.length > max_length ? "#{value[0...max_length]}..." : value
45
+ end
46
+
47
+ def inspect_collection(value)
48
+ max_length = EventTimeline.configuration&.max_inspect_length || 200
49
+ inspected = value.inspect
50
+ inspected.length > max_length ? "#{value.class}[#{value.size} items]" : inspected
51
+ end
52
+
53
+ def inspect_activemodel(model)
54
+ "#{model.class.name}(#{model.class.attribute_names.size} attributes)"
55
+ rescue StandardError
56
+ "#{model.class.name}(<inspection failed>)"
57
+ end
58
+
59
+ def inspect_generic(value)
60
+ max_length = EventTimeline.configuration&.max_inspect_length || 200
61
+ result = value.inspect
62
+ result.length > max_length ? "#{value.class.name}[#{result.length} chars]" : result
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EventTimeline
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.1'
5
5
  end
@@ -4,6 +4,8 @@ require 'event_timeline/version'
4
4
  require 'event_timeline/engine'
5
5
  require 'event_timeline/configuration'
6
6
  require 'event_timeline/current_correlation'
7
+ require 'event_timeline/value_inspector'
8
+ require 'event_timeline/value_filter'
7
9
  require 'event_timeline/call_tracker'
8
10
  require 'event_timeline/middleware'
9
11
  require 'event_timeline/rotation_service'
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+
6
+ module EventTimeline
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ desc 'Creates an EventTimeline initializer and copies migrations'
14
+
15
+ def self.next_migration_number(dirname)
16
+ next_migration_number = current_migration_number(dirname) + 1
17
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
18
+ end
19
+
20
+ def copy_initializer
21
+ template 'initializer.rb', 'config/initializers/event_timeline.rb'
22
+ end
23
+
24
+ def copy_migrations
25
+ migration_template 'create_event_timeline_sessions.rb',
26
+ 'db/migrate/create_event_timeline_sessions.rb',
27
+ skip: true
28
+ end
29
+
30
+ def show_post_install_message
31
+ say ''
32
+ say 'EventTimeline installed successfully!', :green
33
+ say ''
34
+ say 'Next steps:'
35
+ say ' 1. Run migrations: rails db:migrate'
36
+ say ' 2. Configure watched paths in config/initializers/event_timeline.rb'
37
+ say ' 3. Visit /event_timeline/sessions/:request_id to view timelines'
38
+ say ''
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateEventTimelineSessions < ActiveRecord::Migration[8.1]
3
+ class CreateEventTimelineSessions < ActiveRecord::Migration[7.0]
4
4
  def change
5
5
  create_table :event_timeline_sessions do |t|
6
6
  t.string :name, null: false
7
7
  t.string :severity, default: 'info'
8
8
  t.string :category
9
- t.jsonb :payload
9
+ t.json :payload
10
10
  t.string :correlation_id, null: false
11
11
  t.datetime :occurred_at, null: false
12
12
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ EventTimeline.configure do |config|
4
+ # Configure which paths to track method calls for.
5
+ # Supports glob patterns and specific file paths.
6
+ #
7
+ # Examples:
8
+ # config.watch 'app/services'
9
+ # config.watch 'app/models/order.rb'
10
+ # config.watch 'lib/payment_processor'
11
+ #
12
+ # config.watch 'app/services'
13
+
14
+ # Add additional attributes to filter (beyond the defaults).
15
+ # Default filtered: password, token, secret, key, credential, auth,
16
+ # session, cookie, ssn, social_security, credit_card,
17
+ # card_number, cvv, pin, private, confidential
18
+ #
19
+ # config.add_filtered_attributes :bank_account, :routing_number
20
+
21
+ # Custom PII filtering logic (optional).
22
+ # Return true to filter, false to keep, nil to use default logic.
23
+ #
24
+ # config.filter_pii do |key, value, context|
25
+ # return true if key.to_s =~ /account_number/
26
+ # nil # Fall back to default filtering
27
+ # end
28
+
29
+ # Customize how events are displayed in the timeline (optional).
30
+ #
31
+ # config.narrator do |event|
32
+ # case event.name
33
+ # when /Payment/
34
+ # "[PAYMENT] #{event.name}"
35
+ # else
36
+ # event.name.humanize
37
+ # end
38
+ # end
39
+
40
+ # Data retention settings (defaults shown).
41
+ #
42
+ # config.max_events_per_correlation = 500 # Max events per request
43
+ # config.max_total_events = 10_000 # Max total events stored
44
+ # config.max_event_age = 1.month # Auto-delete events older than this
45
+ end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: event_timeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - svn-arv
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-01-10 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -23,11 +24,39 @@ dependencies:
23
24
  - - ">="
24
25
  - !ruby/object:Gem::Version
25
26
  version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
26
55
  description: EventTimeline tracks method calls in your Rails app so you can replay
27
56
  and debug what happened during any request. Perfect for understanding production
28
57
  issues and unfamiliar codebases.
29
58
  email:
30
- - svn-arv@users.noreply.github.com
59
+ - eventtimelinerails@gmail.com
31
60
  executables: []
32
61
  extensions: []
33
62
  extra_rdoc_files: []
@@ -43,7 +72,6 @@ files:
43
72
  - app/views/event_timeline/sessions/show.html.erb
44
73
  - app/views/layouts/event_timeline/application.html.erb
45
74
  - config/routes.rb
46
- - db/migrate/20260103180115_create_event_timeline_sessions.rb
47
75
  - lib/event_timeline.rb
48
76
  - lib/event_timeline/call_tracker.rb
49
77
  - lib/event_timeline/configuration.rb
@@ -51,10 +79,17 @@ files:
51
79
  - lib/event_timeline/engine.rb
52
80
  - lib/event_timeline/middleware.rb
53
81
  - lib/event_timeline/rotation_service.rb
82
+ - lib/event_timeline/value_filter.rb
83
+ - lib/event_timeline/value_inspector.rb
54
84
  - lib/event_timeline/version.rb
85
+ - lib/generators/event_timeline/install/install_generator.rb
86
+ - lib/generators/event_timeline/install/templates/create_event_timeline_sessions.rb
87
+ - lib/generators/event_timeline/install/templates/initializer.rb
88
+ homepage: https://github.com/svn-arv/event_timeline
55
89
  licenses:
56
90
  - MIT
57
91
  metadata: {}
92
+ post_install_message:
58
93
  rdoc_options: []
59
94
  require_paths:
60
95
  - lib
@@ -62,14 +97,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
62
97
  requirements:
63
98
  - - ">="
64
99
  - !ruby/object:Gem::Version
65
- version: '0'
100
+ version: '3.0'
66
101
  required_rubygems_version: !ruby/object:Gem::Requirement
67
102
  requirements:
68
103
  - - ">="
69
104
  - !ruby/object:Gem::Version
70
105
  version: '0'
71
106
  requirements: []
72
- rubygems_version: 3.7.2
107
+ rubygems_version: 3.4.10
108
+ signing_key:
73
109
  specification_version: 4
74
110
  summary: Debug Rails apps by replaying method calls from any request
75
111
  test_files: []